Merge pull request #2710 from deepseek-harness/perf/client-plugin-batches

perf(client-modules): batch startup plugin scripts
This commit is contained in:
imccyu
2026-08-24 21:06:22 +08:00
committed by GitHub
50 changed files with 1482 additions and 350 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: 02dadf6e1dc1f2c4fd99907446bc6d07b35ba471
2026-07-23-client-plugin-loading-model.zh.md: eaf10d32a6b51189867d2a52f76dc190380cbca0
2026-07-23-client-plugin-loading-model.md: dfa9f34276f20ffa99541db1544539d693313a2f
2026-07-23-client-plugin-loading-model.zh.md: 68fe9b912c60aceb2ecea315ed0121f9f96c1ecf
@@ -14,7 +14,7 @@ The browser client runs the same cordis plugin mechanism, so it needs the same s
Conventional frontend engineering digests all dependencies at build time: one bundle, externals resolved by the bundler, nothing left to manage at runtime. Runtime module management on top of that is the unusual requirement here. The client therefore splits into two layers: the upper layer is cordis plugin loading through the same vendored Loader, and the lower layer is module-granular dependency management — `dsh-client-modules`.
The lower layer supplies four capabilities: externals (the platform list), remote arrival (same-origin external classic scripts plus lazy factory registration), versioning (content-hash revs), and hot update (invalidate/prefetch).
The lower layer supplies four capabilities: externals (the platform list), remote arrival (same-origin external classic scripts plus lazy factory registration), immutable revisioned delivery, and hot update (invalidate/prefetch).
Plugin bundles are built independently outside Vite's module graph. Feeding response text into an inline script leaves the browser with a dynamic source execution: no standard source-map chain connects the network resource, generated bundle, and TypeScript/TSX source, so performance profiles and stacks stop at generated `client.js`; the module system must also buffer the complete source and split one arrival responsibility across fetch and execute transport boundaries.
@@ -28,7 +28,7 @@ The first-generation client loader (`createClientLoader`) hand-wrote both layers
The [client shell layering note](2026-08-15-client-shells-and-dynamic-packages.md) defines the current static and dynamic package sets and the import rules between them. The loading machinery treats every `dsh.client` package as a host-graph row with one ordinary `lib/client.js` factory bundle. Its declaration carries Cordis `inject` edges, synchronous module-table `external` requests, and the optional `immediately` prefetch mark; the composing app owns only the mounted roster.
The web kernel remains framework-free and imports no dynamic package value. Modules is itself a dynamic row, but the host parser delivers its ordinary factory before the Vite main module. The HTML-installed `__ModuleLoader__` facade uses that factory to construct the module system when the kernel calls `create()`. Runtime arrives through the same pending queue; static React, Cordis, and UI library identities come from the shell seed.
The web kernel remains framework-free and imports no dynamic package value. Modules is itself a dynamic row, but the host parser delivers its factory before the Vite main module. The HTML-installed `__ModuleLoader__` facade uses that factory to construct the module system when the kernel calls `create()`. Every other dynamic row arrives through the application batch; static React, Cordis, and UI library identities come from the shell seed.
### One module system, one plugin governor
@@ -38,13 +38,13 @@ The browser mirrors the host's division of labor. `dsh-client-modules` (`ClientM
The vendored Loader consumes the module system through its `internal` contract — the only call site is `tree.import` — and owns everything entry-shaped: entry creation, fiber activation through cordis service waiting (PENDING until injected services exist, cascading when a service is provided), update/refresh, teardown. The governance code is byte-identical to the host side, per vendor policy. Browserization is compile-time mapping in the shell's vite config: a `node:module` stub alias plus `process.*` defines make `ModuleLoader.fromInternal()` return undefined — exactly the empty slot the shell fills. The module system mounts as `ctx.modules`.
### External-script arrival and source maps
### Batched external-script arrival and source maps
Each graph row's `url` goes to a same-origin external classic `<script src>` with `async` set. The browser owns the network request and script execution; the node is removed as soon as `load` or `error` settles so HMR cannot accumulate dead nodes. Successful settlement also requires the graph row's factory id to exist in the module table, or arrival fails; registration still does not run the factory, so the side-effect boundary remains first materialization.
The Host snapshots every built plugin artifact and concatenates its factory registration into one of two same-origin classic scripts. The parser-blocking `bootstrap` batch contains the modules row; the HTML preloads the `application` batch containing every other graph row while bootstrap executes. The module system keys in-flight transport by batch URL, so concurrent row arrivals execute one application 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 shape `/packages/<group>/<package>/src/...`. Other workspace sources inlined into a bundle likewise resolve to their `packages/` owner, while dependency paths remain unchanged; `sourcesContent` carries the source, so the host only serves the map at `/plugins/<id>/client.js.map` and exposes no source route. The Vite shell also emits source maps, letting both shell code and out-of-graph 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 shape `/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. Batch generation strips each local `sourceMappingURL`, records its generated-line offset, resolves every source against the original per-plugin map URL, and emits one indexed Source Map v3 file whose sections embed the available plugin maps. The Vite shell also emits source maps, letting shell code and batched or individually reloaded plugins map stacks and performance profiles back to TypeScript/TSX.
`rev` remains the script URL's query parameter and content-consistency anchor, and the bundle and map are both served with `no-cache`. An external script's `error` event exposes neither response status nor body, so failure diagnostics name only the URL; the same-origin host and build-stamped registration id form the identity boundary, while the post-`load` factory-presence check rejects an artifact that did not register the expected id.
The graph retains each row's revisioned individual URL for HMR and adds content-addressed descriptors for the two startup batches. Initial row revisions are opaque process nonces rather than content hashes; they keep an exceptional initial individual request 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. Versioned scripts and maps use immutable caching. The Host serves snapshotted bytes only when the requested revision matches; stale or missing revisions return 404 instead of aliasing newer bytes. An external script's `error` event exposes neither response status nor body, so failure diagnostics name only the URL; the same-origin Host and build-stamped registration id form the identity boundary, while the post-`load` factory-presence check rejects an artifact that did not register the expected id.
### The loading flow, end to end
@@ -53,12 +53,12 @@ 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? }] }`. The three optional fields come from manifests, never hand-copied. Composition orders requested dynamic rows before their consumers and rejects synchronous request cycles. 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. Each bundle's content hash is its `rev` (cache busting + HMR diff anchor), the row set hashes into `graph.rev`, and every row is served as a script resource at `/plugins/<id>/client.js?rev=…`, with its source map at the same path plus `.map`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph (it is a plain route-registration plugin; modules registers the bundle route and taps the index render itself).
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; batch revisions hash the generated script 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 bundle 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.
**Phase one — the module face.** The injected HTML installs `window.__ModuleLoader__` in queue mode, executes the modules and runtime graph rows as blocking classic scripts, assigns `window.__DSH_BOOT__`, and then starts the Vite main module. The kernel calls the facade's `create()` with the raw graph and shell seeds. The facade removes and materializes the modules registration with a bootstrap `require` that rejects every external, then calls its `createClientModuleSystem` export. The modules bundle parses the graph, constructs the system, memoizes its own exports, and retains the instance in its module closure; construction switches the same facade to live registration before draining runtime's pending factory. The kernel then prefetches every `immediately` row in parallel; prefetch recursively registers declared dynamic requests and the row itself without materializing either. A row's prefetch failure is swallowed here because phase two's import retries and owns the loud failure. `immediately` remains an arrival mark, not a lifecycle barrier or package identity.
**Phase one — the module face.** The injected HTML installs `window.__ModuleLoader__` in queue mode, starts preloading the application batch, executes the bootstrap batch as one blocking classic script, assigns `window.__DSH_BOOT__`, and then starts the Vite main module. The kernel calls the facade's `create()` with the raw graph and shell seeds. The facade removes and materializes the modules registration with a bootstrap `require` that rejects every external, then calls its `createClientModuleSystem` export. The modules bundle parses the graph, constructs the system, memoizes its own exports, retains the instance in its module closure, and switches the same facade to live registration. The kernel then prefetches every `immediately` row in parallel. Their shared application URL executes once and registers every remaining factory without materializing it. A prefetch failure is swallowed here because phase two's import retries and owns the loud failure. `immediately` remains a registration barrier, not a package identity.
**Phase two — the plugin face.**
@@ -72,19 +72,19 @@ Why is the roster yml rows and not a scan? Because which plugins compose into a
Hot reload is a composition decision: the web bundle mounts the `client-hmr` row (a normal plugin package) unconditionally; its node half brings the bundle watch and the SSE channel, and the chain stays idle until a rebuild watcher rewrites client bundles. A composition that must not expose it disables the row.
How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. It reads bundle paths from `ctx.clientModules.clientPath(id)`, and one HMR-owned interval stat-polls every current graph row. Adding a row is ordered as synchronous stat baseline, then immediate `clientModuleHost.rebuilt(id)`: a write after the module host's graph hash but before that baseline is caught by the immediate re-hash, while a write after the baseline leaves a stat delta for the next poll. This avoids `fs.watchFile`, whose asynchronous first baseline can silently absorb a construction-time rebuild. Watch membership follows `onGraphChanged`; vanished rows drop out, and a bundle missing at poll time keeps its row dirty so reappearance forces a re-hash even with identical metadata. On a mtime/size delta or dirty row, `clientModuleHost.rebuilt(id)` is the single re-hash entry point; when the `rev` actually changed, the node half broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Polling is deliberate because inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`; the interval is a validated config field (default 500ms), and disposal clears the one timer. Rebuilding bundles is any tsdown watch process's business — `scripts/dev-web.ts` remains the watch-build entry point, discovering its package list through `dsh.client` while scanning `packages/*/*/package.json` at startup — and builder and host share zero protocol. A torn read self-heals: stats keep changing while the write completes, so the next poll re-hashes and broadcasts the final rev.
How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. Before reading each startup snapshot, the module host captures the bundle and optional-map stat baseline and exposes it through `ctx.clientModules.artifactBaseline(id)`. One HMR-owned interval compares every current graph row with that baseline. An unchanged row starts watching without a content read or hash; a write after baseline capture is already a stat delta and only that row enters `rebuilt(id)`. This avoids both an initial all-row re-hash and `fs.watchFile`, whose asynchronous first baseline can silently absorb a construction-time rebuild. Watch membership follows `onGraphChanged`; vanished rows drop out, and a bundle missing at poll time keeps its row dirty so reappearance forces a re-hash even with identical metadata. On a script/map mtime or size delta, or a dirty row, `rebuilt(id)` is the single re-hash entry point; when the `rev` actually changed, the node half broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Polling is deliberate because inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`; the interval is a validated config field (default 500ms), and disposal clears the one timer. Rebuilding artifacts is any tsdown watch process's business — `scripts/dev-web.ts` remains the watch-build entry point, discovering its package list through `dsh.client` while scanning `packages/*/*/package.json` at startup — and builder and host share zero protocol. A torn read self-heals: stats keep changing while the write completes, so the next poll re-hashes and broadcasts the final rev.
On the browser side, the driver reloads one plugin per frame, serialized:
1. `invalidate` — drop the stale factory and record. A live factory would make the next step a no-op.
2. `prefetch` — load the external script and register the fresh factory, while the old fiber still serves.
1. `invalidate` — drop the stale factory and record, and bind the rebuilt frame's revision to that row's individual URL. A live factory would make the next step a no-op.
2. `prefetch` — load the individual external script and register the fresh factory, while the old fiber still serves. The initial batch never executes again.
3. `registry.delete` — before touching the fiber. A bare fiber dispose trips the vendored Loader's self-dispose branch, which would disable the entry permanently.
4. Drain the old fiber's disposers.
5. Remove owned `<style data-plugin>` tags.
6. `entry.refresh()` — re-imports, materializing the fresh factory. CSS re-injects here, under the same stable tag ids.
7. `fiber.await()` — rethrows loud.
Every plugin shares this one semantics; an `immediately` row reloads exactly like a lazy one. Dependency cascade costs zero client code: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber re-loads every dependent through cordis itself. Reloading connection or runtime cascades the whole UI — correct, if heavy.
Every plugin shares this one semantics; an `immediately` row reloads exactly like a lazy one. Dependency cascade costs zero client code: a fiber's activation epoch strings its service providers' uids, so replacing a foundational provider such as connection re-loads every dependent through cordis itself — correct, if heavy.
The support boundary, stated honestly. Reload is coarse by design: fresh fiber, fresh components, React state lost, data layer untouched — react-refresh-grade state preservation conflicts with "re-executing the bundle re-runs the factory" and is deliberately out. Static assembly packages and the shell kernel are not entries: changing them means a shell rebuild and a full page reload. Reload has no rollback: an import failure leaves the entry fiberless and the next rebuilt frame retries from scratch; an apply failure leaves a FAILED fiber for the status projection; both log loudly. Self-reload works — the in-flight reload finishes in the old bundle's closure and the new apply opens a fresh SSE channel — but frames arriving in the gap are lost, and the next rebuild renotifies. One known dev-only race: a rebuilt frame overlapping a still-in-flight boot arrival shares that arrival's task and may materialize the pre-rebuild bytes; the next frame self-heals.
@@ -96,7 +96,7 @@ The current package inventory and build forms live in the [client shell layering
One governance implementation runs on both sides of the wire; the browser-specific layer is one module system plus one reload plugin. Dynamic packages have one artifact form, so the purity check covers them all. Cordis dependencies, module requests, and the boot tier live with their owners — the manifests — while the composing app holds only the roster. Host graph validation and recursive request arrival keep synchronous factory dependencies explicit. Browser-native script loading preserves the standard mapping among plugin network resources, generated bundles, and TypeScript/TSX sources, while the module system keeps only one replaceable `loadBundle` hook.
Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows are informational — activation truth is service-level — so a mismatch appears at the settled sweep, not at graph validation; the static UI libraries keep direct value exports; every bundle gains a source-map artifact; and external-script failures provide only coarse URL diagnostics instead of the HTTP status available to an explicit fetch.
Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows guide factory arrival but service availability remains the activation authority, so a mismatch appears at the settled sweep; the static UI libraries keep direct value exports; every bundle gains a source-map artifact; and external-script failures provide only coarse URL diagnostics instead of the HTTP status available to an explicit fetch. The Host retains per-plugin bundle/map snapshots, revision-stamped individual responses, current batches, and one previous batch generation, so memory scales as several copies of the composed client artifacts. This retained state keeps URLs immutable and lets an in-flight request finish across one HMR recomposition.
Roster: it lives in the web bundle's config tree (`packages/bundle/web-app/cordis.patch.yml`); `mountWebPlugins` and the `CLIENT_PACKAGES` constant are gone, and recomposing a deployment means swapping the yml/overlay. The graph composer lives in the `dsh-client-modules` node half, while the parser-preloaded client face bootstraps the browser module table. The webserver remains a plain route-registration plugin; `/api/*` binding belongs to the connection node half over `api-gateway` (`dsh-host-apiproxy` providing `ctx.apiProxy`), and the dev bundle watch plus SSE channel belongs to the hmr node half.
@@ -14,7 +14,7 @@ host 侧,cordis 插件装载站在 Node 的模块机制之上——require cac
常规前端工程在构建期消化全部依赖:单一 bundle,external 由打包器解决,运行时无物可管。在此之上再做运行时模块管理,正是这里的特殊需求。client 因此拆成两层:上层是经同一份 vendored Loader 的 cordis 插件装载,下层是模块粒度的依赖管理——`dsh-client-modules`
下层供给四项能力:external(平台清单)、远程到达(同源外部 classic script 加惰性工厂登记)、版本化(内容哈希 rev、热更新(invalidate/prefetch)。
下层供给四项能力:external(平台清单)、远程到达(同源外部 classic script 加惰性工厂登记)、不可变的版本化交付、热更新(invalidate/prefetch)。
插件 bundle 独立构建在 Vite 模块图之外。若把响应文本塞进内联 script,浏览器只能看到一次动态源码执行:网络资源、生成 bundle、TypeScript/TSX 源码之间没有标准 sourcemap 链,性能 profile 与 stack 只能落到生成后的 `client.js`;模块系统还要持有整份源码文本,并把同一项到达职责拆成 fetch 与 execute 两道传输边界。
@@ -28,7 +28,7 @@ host 侧,cordis 插件装载站在 Node 的模块机制之上——require cac
[Client 外壳分层 Note](2026-08-15-client-shells-and-dynamic-packages.zh.md)定义当前的静态、动态包集合及其 import 规则。装载机件把每个 `dsh.client` 包视为一个 host graph row,且每个包只有一个普通 `lib/client.js` factory bundle。包声明携带 Cordis `inject` 边、同步模块表 `external` 请求,以及可选的 `immediately` 预取标记;负责组合的 app 只拥有挂载名册。
Web 内核保持不依赖框架,也不 import 任何动态包实体。Modules 本身是动态图 row,但 host parser 会在 Vite 主模块前送达其普通 factory。内核调用 `create()` 时,由 HTML 安装的 `__ModuleLoader__` facade 使用该 factory 构造模块系统。Runtime 经同一个 pending queue 到达;React、Cordis 与静态 UI 库的身份由外壳 seed 提供。
Web 内核保持不依赖框架,也不 import 任何动态包实体。Modules 本身是动态图 row,但 host parser 会在 Vite 主模块前送达其 factory。内核调用 `create()` 时,由 HTML 安装的 `__ModuleLoader__` facade 使用该 factory 构造模块系统。其他动态图 row 全部经 application 批次到达;React、Cordis 与静态 UI 库的身份由外壳 seed 提供。
### 一套模块系统,一个插件治理器
@@ -38,13 +38,13 @@ Web 内核保持不依赖框架,也不 import 任何动态包实体。Modules
vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点是 `tree.import`——并拥有一切 entry 形状的事务:entry 创建、fiber 经 cordis 服务等待的激活(注入的服务未就位即保持 PENDING,服务 provide 时级联激活)、update/refresh、拆除。治理代码按 vendor 政策与 host 侧逐字节相同。浏览器化是壳 vite 配置里的编译期映射:一个 `node:module` stub 别名加若干 `process.*` define,使 `ModuleLoader.fromInternal()` 返回 undefined——这正是留给壳来填的空槽。模块系统挂载为 `ctx.modules`
### 外部脚本到达与源码映射
### 批量外部脚本到达与源码映射
每个图行的 `url` 交给一个带 `async` 的同源外部 classic `<script src>`。浏览器拥有网络请求与脚本执行;`load``error` 结算后节点立即移除,避免 HMR 累积失效节点。成功结算要求图行对应的工厂 id 已出现在模块表中,否则到达失败;登记仍不运行工厂,副作用边界继续落在首次物化。
Host 会快照每个已构建插件产物,并把其 factory registration 拼入两个同源 classic script 之一。阻塞 parser 的 `bootstrap` 批次包含 modules rowHTML 在 bootstrap 执行期间预加载包含其余全部 graph row 的 `application` 批次。模块系统按批次 URL 复用进行中的传输,因此并发 row 到达只执行一次 application 脚本。成功结算要求模块表中已经存在被请求 row 的 factory id;登记不会运行 factory,所以副作用边界依然是首次物化。
共享 tsdown 预设为每个插件产出 `client.js.map`,并把第一方源码路径重写成浏览器可识别的仓库形状 `/packages/<group>/<package>/src/...`内联进 bundle 的其他 workspace 源码同样回到其 `packages/` 归属,依赖包路径保持原样;`sourcesContent` 承载源码,因此 host 只需在 `/plugins/<id>/client.js.map` 供给 map,无需开放源码路由。Vite 壳也产出 sourcemap,使壳代码与图外插件都能从 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/` 归属,依赖包路径保持原样。批次生成会移除每个局部 `sourceMappingURL`、记录其生成行偏移、以原插件 map URL 解析每个 source,再产出一份以 section 内嵌现有插件 map 的 indexed Source Map v3 文件。Vite 壳也产出 sourcemap,使壳代码以及批量或独立重载的插件都能从 stack 和性能 profile 回到 TypeScript/TSX。
`rev` 继续作为脚本 URL 的查询参数和内容一致性锚点,bundle 与 map 都以 `no-cache` 供给。外部脚本的 `error` 事件不给响应状态与正文,因此失败诊断只报告 URL;同源 host 供给与构建期写入的 registration id 是身份边界,`load` 后的工厂存在性检查负责拒绝未登记预期 id 的产物。
图为 HMR 保留每个 row 带 revision 的独立 URL,并为两个启动批次增加按内容寻址的描述。初始 row revision 是进程级不透明 nonce,而不是内容哈希;它无需在启动时哈希每个插件,也能保证异常情况下的初始独立请求不可变。watcher 观察到某个产物变化后,`rebuilt(id)` 只哈希该 bundle 与 map,并发布所得 revision。版本化脚本与 map 使用 immutable 缓存。Host 只在请求 revision 匹配时提供已快照字节;陈旧或缺失 revision 返回 404,不会在旧 URL 下别名到新字节。外部脚本的 `error` 事件不给响应状态与正文,因此失败诊断只报告 URL;同源 Host 与构建期写入的 registration id 是身份边界,`load` 后的 factory 存在性检查负责拒绝未登记预期 id 的产物。
### 装载流程,端到端
@@ -53,12 +53,12 @@ vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点
**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? }] }`三个可选字段都来自 manifest,永不人肉抄写。组合会把被请求的动态图 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,初扫与稳态共享一条实现。每个 bundle 的内容哈希是其 `rev`(缓存失效 + HMR diff 锚点),行集合哈希进 `graph.rev`,每一行都作为脚本资源供给:`/plugins/<id>/client.js?rev=…`,对应 sourcemap 位于同一路径加 `.map`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知(它是朴素路由注册插件;bundle 路由 index 渲染 tap 都由 modules 自己注册)
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 加序号,不对其产物求哈希;批次 revision 对生成的脚本及 indexed map 求哈希,row 与批次描述再共同哈希进 `graph.rev`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知modules 会注册 bundle 路由并贡献结构化 index 注入行
为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个在仓库中声明了 dsh.client 的包,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。
**第一阶段——模块面。**注入的 HTML 以 queue 模式安装 `window.__ModuleLoader__`以阻塞式 classic script 执行 modules 与 runtime graph row,赋值 `window.__DSH_BOOT__`,然后启动 Vite 主模块。内核把原始图和外壳 seed 传给 facade 的 `create()`。Facade 移除 modules registration,用拒绝全部 external 的 bootstrap `require` 将其物化,再调用其 `createClientModuleSystem` 导出。Modules bundle 解析图、构造系统、记忆化自身 exports,并在模块闭包中保留该实例;构造过程先把同一 facade 切换到 live registration,再排空 runtime 的 pending factory。随后内核并行预取每个 `immediately` rowprefetch 会递归登记已声明的动态请求和 row 自身,但不物化任一项。单行预取失败在这里被吞下,因为第二阶段 import 会重试并拥有那次大声失败。`immediately` 仍是到达标记,不是生命周期屏障或包身份。
**第一阶段——模块面。**注入的 HTML 以 queue 模式安装 `window.__ModuleLoader__`开始预加载 application 批次,以一个阻塞式 classic script 执行 bootstrap 批次,赋值 `window.__DSH_BOOT__`,然后启动 Vite 主模块。内核把原始图和外壳 seed 传给 facade 的 `create()`。Facade 移除 modules registration,用拒绝全部 external 的 bootstrap `require` 将其物化,再调用其 `createClientModuleSystem` 导出。Modules bundle 解析图、构造系统、记忆化自身 exports在模块闭包中保留该实例,并把同一 facade 切换到 live registration。随后内核并行预取每个 `immediately` row它们共享的 application URL 只执行一次,并登记其余全部 factory 而不物化。预取失败在这里被吞下,因为第二阶段 import 会重试并拥有那次大声失败。`immediately` 仍是 registration barrier,不是包身份。
**第二阶段——插件面。**
@@ -72,19 +72,19 @@ vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点
热重载是一项组合决策:web 组合包无条件挂载 `client-hmr` 行(一个常规的插件包),其 node 半带来 bundle 监视与 SSEServer-Sent Events)通道;没有重建 watcher 改写客户端 bundle 时链路保持空闲。不应暴露它的组合可以禁用该行。
重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。它从 `ctx.clientModules.clientPath(id)` 读取图上各行的 bundle 路径,由 HMR 自持的单个定时器当前图的每一行做 stat 轮询。新增图行时,顺序固定为先同步取得 stat 基线,再立即调用 `clientModuleHost.rebuilt(id)`:在模块 host 算出图哈希之后、取得基线之前发生的写入会被这次立即重哈希捕获;取得基线之后发生的写入则会留下 stat 差异,供下一次轮询捕获。这避开 `fs.watchFile`:它以异步首次 stat 建立基线可能把构造期间的重建静默吸收进基线。监视集合的成员随 `onGraphChanged` 更新;消失的撤下监视,轮询时缺失的 bundle 则让对应保持标脏状态,文件重现时即使元数据相同也强制重哈希。mtime/size 变化或行处于标脏状态时,`clientModuleHost.rebuilt(id)` 是重哈希的唯一入口;当 `rev` 真的变了,node 半才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建 bundle 则是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。
重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。模块 host 在读取每份启动快照前捕获 bundle 与可选 map 的 stat 基线,并通过 `ctx.clientModules.artifactBaseline(id)` 暴露它。HMR 自持的单个定时器当前图的每个 row 与这份基线比较:未变化的 row 直接开始监视,不读取内容也不求哈希;基线捕获后的写入已经形成 stat 差异,只有该 row 会进入 `rebuilt(id)`。这同时消除了启动期的全量重哈希,并避开 `fs.watchFile` 以异步首次 stat 建立基线可能静默吸收构造期重建的问题。监视集合的成员随 `onGraphChanged` 更新;消失的 row 撤下监视,轮询时缺失的 bundle 则让对应 row 保持标脏状态,文件重现时即使元数据相同也强制重哈希。脚本/map 的 mtimesize 变化,或 row 处于标脏状态时,`rebuilt(id)` 是重哈希的唯一入口;当 `rev` 真的变了,node 半才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建产物是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。
浏览器侧,驱动插件每帧重载一个插件,串行执行:
1. `invalidate`——丢弃陈旧的工厂与记录。工厂还活着会让下一步变成 no-op。
2. `prefetch`——加载外部脚本并登记新工厂,旧 fiber 此刻仍在服役。
1. `invalidate`——丢弃陈旧的 factory 与记录,并把 rebuilt 帧的 revision 绑定到该 row 的独立 URL。Factory 还活着会让下一步变成 no-op。
2. `prefetch`——加载独立外部脚本并登记新 factory,旧 fiber 此刻仍在服役。初始批次不会再次执行。
3. `registry.delete`——先于任何 fiber 操作。裸做 fiber dispose 会触发 vendored Loader 的自 dispose 分支,把 entry 永久停用。
4. 排空旧 fiber 的各 disposer。
5. 移除名下的 `<style data-plugin>` 标签。
6. `entry.refresh()`——重新 import,物化新工厂。CSS 在这里重新注入,沿用同一批稳定标签 id。
7. `fiber.await()`——让失败大声重抛。
每个插件都共享同一套语义;`immediately` 行的重载与 lazy 行分毫不差。依赖级联不花一行 client 代码:fiber 的激活纪元串接着它各服务提供方的 uid,因此换掉提供方的 fiber,每个依赖方都会经 cordis 本身重新装载。重载 connection 或 runtime 会级联整个 UI——正确,虽然重
每个插件都共享同一套语义;`immediately` 行的重载与 lazy 行分毫不差。依赖级联不花一行 client 代码:fiber 的激活纪元串接着它各服务提供方的 uid,因此替换 connection 等基础 provider 的 fiber,每个依赖方都会经 cordis 本身重新装载——行为正确,但代价较高
支持边界,如实陈述。重载粒度刻意做粗:全新 fiber、全新组件、React 状态丢失、数据层不动——react-refresh 级的状态保留与「重执行 bundle 即重跑 factory」相冲突,属刻意不做。静态装配包与外壳内核不是 entry:改动它们意味着外壳重建加整页刷新。重载不做回滚:import 失败让 entry 失去 fiber,下一个 rebuilt 帧从头重试;apply 失败留下 FAILED fiber 交给状态投影;两者都大声记录。自我重载可行——在途的重载在旧 bundle 的闭包里跑完,新的 apply 再开一条新 SSE 通道——但空窗期到达的帧会丢失,下次重建会再次通知。一处已知的仅限 dev 竞态:rebuilt 帧与仍在途的 boot 到达重叠时共享那次到达的任务,可能物化重建前的字节;下一帧自愈。
@@ -96,7 +96,7 @@ vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点
Wire 两侧运行同一份治理实现;浏览器特有层只包含一套模块系统和一个重载插件。动态包只有一种产物形态,因此纯度检查覆盖全部动态包。Cordis 依赖、模块请求与启动档位都与其所有者——manifest——同住,负责组合的 app 只握名册。Host graph 校验与递归请求到达使同步 factory 依赖保持显式。浏览器原生 script 装载保留插件网络资源、生成 bundle 与 TypeScript/TSX 源码之间的标准映射,模块系统也只保留一个可替换的 `loadBundle` 钩子。
接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;graph `inject` row 仅是信息性说明——激活的真相在服务层——因此不匹配会在 settled 扫描时浮出,而不是在 graph 校验时被拦下;静态 UI 库保留直接实体导出;每个 bundle 多出一份 sourcemap 产物,外部 script 失败也只能给出粗粒度 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。
接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;graph `inject` row 指导 factory 到达,但服务可用性仍是激活权威,因此不匹配会在 settled 扫描时浮出;静态 UI 库保留直接实体导出;每个 bundle 多出一份 sourcemap 产物,外部 script 失败也只能给出粗粒度 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。Host 会保留逐插件 bundle/map 快照、带 revision 的独立响应、当前批次及上一代批次,因此内存会随组合出的客户端产物增长为数份副本。这组保留状态使 URL 保持不可变,并让进行中的请求跨越一次 HMR 重组后仍能完成。
名册位于 web 组合包的配置树(`packages/bundle/web-app/cordis.patch.yml`);`mountWebPlugins``CLIENT_PACKAGES` 常量已消失,重组一次部署等于替换 yml/overlay。Graph 组合器位于 `dsh-client-modules` node 半,由 parser 预载的 client face 则自举浏览器模块表。Webserver 继续作为朴素路由注册插件;`/api/*` 绑定属于 connection node 半,并经 `api-gateway`(由 `dsh-host-apiproxy` 提供 `ctx.apiProxy`);开发期 bundle 监视与 SSE 通道属于 hmr 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-15-client-shells-and-dynamic-packages.md
2026-08-15-client-shells-and-dynamic-packages.md: a92300663bac3bfe04768cf2a4f0c354c4c0c66f
2026-08-15-client-shells-and-dynamic-packages.zh.md: 5bd55558708f8e949ed3700f145c0725554f4d4b
2026-08-15-client-shells-and-dynamic-packages.md: 8713234735ddb53db27e17a2ea9087de665d86f7
2026-08-15-client-shells-and-dynamic-packages.zh.md: ec8a2fb358e86f4fe74db13d555b8be8432ad62e
@@ -10,7 +10,7 @@ English | [中文](2026-08-15-client-shells-and-dynamic-packages.zh.md)
Client npm dependency sections describe installation and development relationships, but they do not reliably describe bundle contents. Treating `dependencies`, `peerDependencies`, or `devDependencies` as implicit bundler instructions can inline a shared React or workspace identity, or leave a built library carrying unresolved child imports without the host that is meant to assemble them.
The browser application also contains distinct roles: the HTML/Vite compilation entry, the framework-free Cordis startup kernel, static assembly libraries, and Loader-governed plugins. Early execution from HTML is an arrival policy, not a package kind. Runtime and modules need to arrive before the Vite main module while retaining ordinary `lib/client.js` artifacts and dynamic graph rows.
The browser application also contains distinct roles: the HTML/Vite compilation entry, the framework-free Cordis startup kernel, static assembly libraries, and Loader-governed plugins. Early execution from HTML is an arrival policy, not a package kind. Modules must arrive before the Vite main module while retaining its ordinary `lib/client.js` artifact and dynamic graph row.
Shared UI libraries still expose synchronous TypeScript and React values to many consumers. Until those values move behind services or slots, making the libraries formal dynamic entries would preserve the value coupling while obscuring which module identity the shell must share.
@@ -24,7 +24,7 @@ Shared UI libraries still expose synchronous TypeScript and React values to many
| Startup kernel | `packages/client/web` | Owns the plain-DOM boot page, module-system wiring, Cordis settlement, and renderer handoff | `staticLinked` `lib/index.js`; no `dsh.client` row |
| Static assembly libraries | Cordis, `ui-primitives`, `ui-slots` | Supply shared module identities and direct value APIs | ESM `lib/index.js`, merged and chunked by Vite; not Loader entries |
| Module bootstrap | `packages/client/modules` | Supplies the client module table and its Cordis wrapper | Dynamic package with one ordinary `lib/client.js`; the host delivers its factory early |
| Dynamic client packages | runtime, `ui-renderer`, theme, and feature plugins | Participate through Cordis services, slots, and effects | Declare `dsh.client`, emit self-registering `lib/client.js`, and remain host-graph entries |
| Dynamic client packages | connection, `ui-renderer`, theme, and feature plugins | Participate through Cordis services, slots, and effects | Declare `dsh.client`, emit self-registering `lib/client.js`, and remain host-graph entries |
`packages/client/web` keeps Cordis as matching peer and development dependencies and uses modules and static UI packages as development compilation inputs. `apps/web` consumes built package exports rather than aliases into workspace source.
@@ -32,7 +32,7 @@ The `staticLinked` preset leaves every bare specifier as an external import in `
### Shared module requests
Dynamic browser bundles implicitly externalize the common baseline: `PLATFORM_MODULES` names shell-seeded React, Cordis, and static UI identities, while `PRELOADED_CLIENT_EXTERNALS` names runtime's parser-preloaded dynamic identity. A package uses `dsh.client.external` only for an exact non-baseline value request. Type-only imports are erased and create no request; permitted third-party implementation libraries remain private bundle contents.
Dynamic browser bundles implicitly externalize the common baseline: `PLATFORM_MODULES` names shell-seeded React, Cordis, and static UI identities, while `PRELOADED_CLIENT_EXTERNALS` is reserved for a dynamic identity that must arrive before shell boot and is currently empty. A package uses `dsh.client.external` only for an exact non-baseline value request. Type-only imports are erased and create no request; permitted third-party implementation libraries remain private bundle contents.
A request has exactly two suppliers:
@@ -46,12 +46,12 @@ There is no general `dsh.client.provide` alias mechanism. Dynamic rows and stati
The modules Node half injects the startup protocol into the served HTML in this order:
1. Install `window.__ModuleLoader__` in queue mode with `pendingQueue`, `load()`, and `create()`.
2. Execute the modules graph row's ordinary `lib/client.js` as a blocking classic script.
3. Execute runtime's ordinary `lib/client.js` the same way.
4. Assign `window.__DSH_BOOT__`.
2. Start preloading the content-addressed application batch containing every row except modules.
3. Execute one blocking bootstrap batch containing the ordinary modules factory registration.
4. Assign `window.__DSH_BOOT__`, including both batch descriptors and every row's individual HMR URL.
5. Execute the Vite main module.
Both early scripts only register factories. The startup kernel passes the raw graph and shell seeds to `__ModuleLoader__.create()`. The facade removes the modules registration, materializes it with a `require` function that rejects every external, and invokes its `createClientModuleSystem` export. The modules bundle parses the graph, constructs `ClientModuleSystem`, caches its own exports as the modules row, and retains the system in a module closure. Construction switches the same facade to live mode before draining runtime's pending factory. The modules client face consequently has a zero-runtime-external bootstrap requirement.
The bootstrap batch only registers the modules factory. The startup kernel passes the raw graph and shell seeds to `__ModuleLoader__.create()`. The facade removes the modules registration, materializes it with a `require` function that rejects every external, and invokes its `createClientModuleSystem` export. The modules bundle parses the graph, constructs `ClientModuleSystem`, caches its own exports as the modules row, retains the system in a module closure, and switches the same facade to live mode. The modules client face consequently has a zero-external bootstrap requirement.
After the `immediately` tier has registered its factories, the kernel creates all Loader entries, awaits Cordis quiescence, and requires every fiber to be ACTIVE. It then calls `ctx.uiRenderer.mount(container)`. The dynamic `ui-renderer` package owns React, slot rendering, hydration of the existing boot DOM, and the React root lifecycle; the startup kernel and failure page remain React-free.
@@ -67,7 +67,7 @@ Ordinary installed libraries remain `dependencies`: a dynamic build may bundle a
**Convert every client package into a dynamic plugin immediately.** `ui-primitives` and `ui-slots` still provide synchronous values without independent service or slot lifecycles; a manifest declaration alone would not remove those imports.
**Generate a separate `client-static.js` for modules or runtime.** Both packages remain dynamic graph rows and Cordis plugins; only their factory arrival is early. A second artifact would encode host policy in a filename and create two runtime products from one source.
**Generate a separate `client-static.js` for modules.** The package remains a dynamic graph row and Cordis plugin; only its factory arrival is early. A second artifact would encode host policy in a filename and create two runtime products from one source.
**Compile all shared modules into the Vite entry.** This would remove deployment composition and plugin-level replacement from business plugins, including the renderer and theme.
@@ -79,7 +79,7 @@ Ordinary installed libraries remain `dependencies`: a dynamic build may bundle a
Bundle contents stay stable when an npm dependency moves between peer and development sections, because each build face declares externality directly. Static libraries remain host-assembled, while dynamic packages retain uniform artifacts and lifecycle governance.
The startup protocol depends on the modules and runtime package ids, and modules must remain self-contained at runtime. A missing bootstrap registration fails before Cordis starts; later plugin import, apply, and service-wait failures remain visible through the boot page's ACTIVE scan.
The startup protocol depends on the modules package id, and modules must remain self-contained at runtime. Batch generation preserves its ordinary package artifact and gives every other row one shared initial transport; HMR still uses each row's revisioned individual artifact. A missing bootstrap registration fails before Cordis starts; later plugin import, apply, and service-wait failures remain visible through the boot page's ACTIVE scan.
The shell consumes built `lib/` products, so source and browser artifacts can drift until the relevant build or watcher runs. Typechecking source alone does not prove the served application uses the same code.
@@ -10,7 +10,7 @@ Status: implemented
Client npm 依赖区段描述安装和开发关系,但不能可靠描述 bundle 内容。把 `dependencies``peerDependencies``devDependencies` 当作隐式 bundler 指令,可能内联本应共享的 React 或 workspace 身份,也可能让构建后的库携带未解析子 import,却没有交给预期的宿主组装。
浏览器应用还包含不同角色:HTML/Vite 编译入口、不依赖框架的 Cordis 启动内核、静态装配库,以及由 Loader 治理的插件。HTML 提前执行属于到达策略,不定义包类别。Runtime 和 modules 需要先于 Vite 主模块到达,同时继续使用普通 `lib/client.js` 产物和动态图 row。
浏览器应用还包含不同角色:HTML/Vite 编译入口、不依赖框架的 Cordis 启动内核、静态装配库,以及由 Loader 治理的插件。HTML 提前执行属于到达策略,不定义包类别。Modules 必须先于 Vite 主模块到达,同时继续使用普通 `lib/client.js` 产物和动态图 row。
共享 UI 库仍向大量消费者暴露同步 TypeScript 与 React 实体。在这些实体进入 service 或 slot 前,形式上把库改为动态 entry 只会保留实体耦合,并模糊外壳必须共享的模块身份。
@@ -24,7 +24,7 @@ Client npm 依赖区段描述安装和开发关系,但不能可靠描述 bundl
| 启动内核 | `packages/client/web` | 拥有纯 DOM 启动页、模块系统接线、Cordis settle 和 renderer handoff | `staticLinked` `lib/index.js`;无 `dsh.client` row |
| 静态装配库 | Cordis、`ui-primitives``ui-slots` | 提供共享模块身份和直接实体 API | ESM `lib/index.js`,由 Vite 合并拆分;不是 Loader entry |
| 模块自举包 | `packages/client/modules` | 提供 client 模块表及其 Cordis wrapper | 带一个普通 `lib/client.js` 的动态包;host 提前送达其 factory |
| 动态 client 包 | runtime`ui-renderer`、主题和功能插件 | 通过 Cordis service、slot 和 effect 参与应用 | 声明 `dsh.client`,产出自注册 `lib/client.js`,并保留 host graph entry |
| 动态 client 包 | connection`ui-renderer`、主题和功能插件 | 通过 Cordis service、slot 和 effect 参与应用 | 声明 `dsh.client`,产出自注册 `lib/client.js`,并保留 host graph entry |
`packages/client/web` 把 Cordis 保持为 matching peer 与开发依赖,并把 modules 和静态 UI 包作为开发期编译输入。`apps/web` 消费已构建 package export,不通过 alias 读取 workspace 源码。
@@ -32,7 +32,7 @@ Client npm 依赖区段描述安装和开发关系,但不能可靠描述 bundl
### 共享模块请求
动态浏览器 bundle 会隐式 external 统一基座:`PLATFORM_MODULES` 命名由外壳播种的 React、Cordis 和静态 UI 身份,`PRELOADED_CLIENT_EXTERNALS` 命名由 HTML parser 预载的 runtime 动态身份。包只在精确请求基座外实体时使用 `dsh.client.external`。纯类型 import 会被擦除,不产生请求;允许的第三方实现库保留为 bundle 私有内容。
动态浏览器 bundle 会隐式 external 统一基座:`PLATFORM_MODULES` 命名由外壳播种的 React、Cordis 和静态 UI 身份,`PRELOADED_CLIENT_EXTERNALS` 则为必须先于 shell 启动到达的动态身份预留,当前为空。包只在精确请求基座外实体时使用 `dsh.client.external`。纯类型 import 会被擦除,不产生请求;允许的第三方实现库保留为 bundle 私有内容。
请求只有两种提供方:
@@ -46,12 +46,12 @@ Client npm 依赖区段描述安装和开发关系,但不能可靠描述 bundl
Modules Node 半按以下顺序向实际返回的 HTML 注入启动协议:
1. 以 queue 模式安装 `window.__ModuleLoader__`,包含 `pendingQueue``load()``create()`
2. 以阻塞式 classic script 执行 modules graph row 的普通 `lib/client.js`
3. 以相同方式执行 runtime 的普通 `lib/client.js`
4. 赋值 `window.__DSH_BOOT__`
2. 开始预加载按内容寻址的 application 批次,其中包含 modules 之外的全部 row
3. 执行一个阻塞式 bootstrap 批次,其中包含普通的 modules factory registration
4. 赋值 `window.__DSH_BOOT__`,其中包含两个批次描述及每个 row 的独立 HMR URL
5. 执行 Vite 主模块。
两个提前执行的脚本都只注册 factory。启动内核把原始图与外壳 seed 传给 `__ModuleLoader__.create()`。Facade 移除 modules registration,用拒绝全部 external 的 `require` 函数将其物化,再调用其 `createClientModuleSystem` 导出。Modules bundle 解析图、构造 `ClientModuleSystem`、把自身 exports 缓存为 modules row,并在模块闭包中保留该系统。构造过程先把同一 facade 切换到 live 模式,再排空 runtime 的 pending factory。因此 modules client face 必须满足零 runtime external 的自举要求。
Bootstrap 批次只登记 modules factory。启动内核把原始图与外壳 seed 传给 `__ModuleLoader__.create()`。Facade 移除 modules registration,用拒绝全部 external 的 `require` 函数将其物化,再调用其 `createClientModuleSystem` 导出。Modules bundle 解析图、构造 `ClientModuleSystem`、把自身 exports 缓存为 modules row在模块闭包中保留该系统,并把同一 facade 切换到 live 模式。因此 modules client face 必须满足零 external 的自举要求。
`immediately` 层级完成 factory 注册后,内核创建全部 Loader entry,等待 Cordis 静止,并要求每个 fiber 都进入 ACTIVE。随后调用 `ctx.uiRenderer.mount(container)`。动态 `ui-renderer` 包拥有 React、slot 渲染、已有启动 DOM 的 hydrate 和 React root 生命周期;启动内核与失败页保持 React-free。
@@ -67,7 +67,7 @@ Modules Node 半按以下顺序向实际返回的 HTML 注入启动协议:
**立即把所有 client 包改为动态插件。** `ui-primitives``ui-slots` 仍提供同步实体,且没有独立 service 或 slot 生命周期;只加 manifest 声明不会移除这些 import。
**为 modules 或 runtime 生成单独的 `client-static.js`。** 两个包仍是动态图 row 和 Cordis 插件,只有 factory 提前到达。第二份产物会把宿主策略编码进文件名,并让同一源码产生两个运行期产品。
**为 modules 生成单独的 `client-static.js`。** 包仍是动态图 row 和 Cordis 插件,只有 factory 提前到达。第二份产物会把宿主策略编码进文件名,并让同一源码产生两个运行期产品。
**把全部共享模块编进 Vite entry。** 这会让业务插件失去部署组合与插件级替换能力,包括 renderer 和主题。
@@ -79,7 +79,7 @@ Modules Node 半按以下顺序向实际返回的 HTML 注入启动协议:
Npm 依赖在 peer 与开发区段间移动时,bundle 内容保持稳定,因为每个构建 face 都直接声明 external。静态库继续由宿主装配,动态包则保留统一产物与生命周期治理。
启动协议依赖 modules 和 runtime 的 package idmodules 还必须保持运行期自包含。缺少 bootstrap registration 会在 Cordis 启动前失败;后续插件 import、apply 与 service 等待失败仍由启动页的 ACTIVE 扫描呈现。
启动协议依赖 modules 的 package idmodules 还必须保持运行期自包含。批次生成保留其普通 package 产物,并为其他全部 row 提供一条共享初始传输;HMR 仍使用每个 row 带 revision 的独立产物。缺少 bootstrap registration 会在 Cordis 启动前失败;后续插件 import、apply 与 service 等待失败仍由启动页的 ACTIVE 扫描呈现。
外壳消费已构建 `lib/` 产品,因此在相关 build 或 watcher 运行前,源码与浏览器产物可能漂移。仅源码 typecheck 通过不能证明实际服务的应用使用同一份代码。
@@ -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-19-web-index-injection-table.md
2026-08-19-web-index-injection-table.md: 9ed02aa94cd318d107a32802d8723652e6b10ea2
2026-08-19-web-index-injection-table.zh.md: 8ad036766faa14071b20da12ef907ab012cae23f
2026-08-19-web-index-injection-table.md: 050690946a73ce453946f6d1152c9e86e1ba3aaf
2026-08-19-web-index-injection-table.zh.md: 27d1cbce1dc39ebe22e0b79e52f9ce935279ee69
@@ -10,16 +10,16 @@ The web shell's boot HTML needs three kinds of injection: client-modules' boot p
## Decision
Make the injection surface an event over pure data: the webserver declares the `webserver/index-inject` event and the `IndexInjection` row union (`global`/`script`/`script-src`/`style`/`html`, `head|body` placement). A plugin that wants to inject subscribes and pushes rows; every collection (`collectIndexInjections()`) is a fresh emit, so subscribers read live state at emit time (module graph, theme preference — no re-registration staleness), and a subscription dies with its fiber.
Make the injection surface an event over pure data: the webserver declares the `webserver/index-inject` event and the `IndexInjection` row union (`global`/`script`/`script-src`/`script-preload`/`style`/`html`, with placement where applicable). A plugin that wants to inject subscribes and pushes rows; every collection (`collectIndexInjections()`) is a fresh emit, so subscribers read live state at emit time (module graph, theme preference — no re-registration staleness), and a subscription dies with its fiber.
One table, two renderers: the served form's `webServer.renderIndex(html)` renders rows into index.html deterministically (head rows after the opening head tag, body rows after the opening body tag; `<` JSON-escaped in global values, attribute-escaped `src`); the worker form's `/__boot__` payload is `{ injections }`, executed row by row by a small page-side interpreter (set global / create script element / load external through the tunnel's `loadBundle` / mount style and markup). Rows are pure JSON data — that is the both-ends-equivalent discipline.
One table, two renderers: the served form's `webServer.renderIndex(html)` renders rows into index.html deterministically (head rows after the opening head tag, body rows after the opening body tag; `<` JSON-escaped in global values, attribute-escaped `src`); the worker form's `/__boot__` payload is `{ injections }`, executed row by row by a small page-side interpreter (set global / create script element / load external through the tunnel's `loadBundle` / mount style and markup). A `script-preload` row renders a browser preload hint in served HTML and is ignored by the worker interpreter, whose `/plugins` resources exist only behind the tunnel and load on demand. Rows are pure JSON data — that is the both-ends-equivalent discipline.
`tapIndex`/`applyIndexTaps` survive as the raw-HTML escape hatch, applied after row rendering; every internal consumer moved to the event.
## Consequences
- client-modules and ui-theme no longer regex-edit HTML; the worker's `readBootPayload` service-poking (`clientModules`, `settings`, theme constants through `loader.load`) is deleted; the page-side `installModuleLoaderFacade`, `applyBootTheme`, and `PARSER_PRELOAD_IDS` re-implementations retire.
- Ordering: across subscribers, subscription order (same as the old tap order); within one subscriber, push order — modules itself guarantees queue → preloads → global.
- Ordering: across subscribers, subscription order (same as the old tap order); within one subscriber, push order — modules itself guarantees queue → application preload → bootstrap script → global.
- The served rendering of the manifest global changed from `window.__DSH_BOOT__ =` to `globalThis["__DSH_BOOT__"] =`; no committed snapshot expectation carries that text, so none needed re-recording.
- New model-visible or page-visible boot inputs extend the row union; no new tap consumers.
@@ -10,16 +10,16 @@ Web 壳的启动 HTML 需要三类注入:client-modules 的引导协议(`__M
## Decision
注入面事件化、数据化:webserver 声明 `webserver/index-inject` 事件与纯数据行类型 `IndexInjection``global`/`script`/`script-src`/`style`/`html``head|body` 定位)。想注入的插件订阅事件、往表里 push 行;每次收集(`collectIndexInjections()`)都是一次全新 emit,订阅方现读现填(模块图、主题偏好天然新鲜,无重注册问题),订阅随 fiber 销毁自动摘除。
注入面事件化、数据化:webserver 声明 `webserver/index-inject` 事件与纯数据行类型 `IndexInjection``global`/`script`/`script-src`/`script-preload`/`style`/`html`在适用的行上携带定位)。想注入的插件订阅事件、往表里 push 行;每次收集(`collectIndexInjections()`)都是一次全新 emit,订阅方现读现填(模块图、主题偏好天然新鲜,无重注册问题),订阅随 fiber 销毁自动摘除。
一张表两个渲染器:served 形态 `webServer.renderIndex(html)` 确定性把行渲染进 index.htmlhead 行插 head 首、body 行插 body 首,全局值 JSON `<` 转义、src 属性转义);worker 形态 `/__boot__` 载荷就是 `{ injections }`,页面侧小解释器逐行执行(设全局 / 建脚本元素 / 经 tunnel loadBundle 载外链 / 挂样式与 DOM)。行是纯 JSON 数据,这是双端等价的纪律。
一张表两个渲染器:served 形态 `webServer.renderIndex(html)` 确定性把行渲染进 index.htmlhead 行插 head 首、body 行插 body 首,全局值 JSON `<` 转义、src 属性转义);worker 形态 `/__boot__` 载荷就是 `{ injections }`,页面侧小解释器逐行执行(设全局 / 建脚本元素 / 经 tunnel loadBundle 载外链 / 挂样式与 DOM)。`script-preload` 行在 served HTML 中渲染为浏览器预加载提示;worker 解释器忽略它,因为 `/plugins` 资源只存在于 tunnel 后方,并在实际需要时加载。行是纯 JSON 数据,这是双端等价的纪律。
`tapIndex`/`applyIndexTaps` 保留为原始 HTML 变换的逃生口,在行渲染之后执行;内部消费者全部迁走。
## Consequences
- client-modules 与 ui-theme 不再各自正则改 HTMLworker 侧 `readBootPayload``ctx.get` 手掏(clientModules、settings、theme 常量 loader.load)删除;页面侧 `installModuleLoaderFacade``applyBootTheme``PARSER_PRELOAD_IDS` 三份重抄退役。
- 顺序语义:跨订阅方按订阅注册顺序(与旧 tap 顺序一致),单订阅方内按 push 顺序;modules 自己保证 队列→preload→全局 三行有序。
- 顺序语义:跨订阅方按订阅注册顺序(与旧 tap 顺序一致),单订阅方内按 push 顺序;modules 自己保证队列→application preload→bootstrap script→全局的顺序。
- `__DSH_BOOT__` 的 served 渲染文本从 `window.__DSH_BOOT__ =` 变为 `globalThis["__DSH_BOOT__"] =`;已核实无已提交快照期望含此文本,无需重录。
- 新的模型可见/页面可见注入一律走行类型扩展,不再新增 tap 消费者。
+67 -22
View File
@@ -7,14 +7,14 @@
//
// Keyless and deterministic: the fixture is the fake server, so nothing here
// reaches a model or the network.
import { readFileSync } from 'node:fs'
import { globSync, readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { act, cleanup } from '@testing-library/react'
import { afterEach, beforeEach, vi } from 'vitest'
import { bootInjections, orderByModuleGraph } from '@deepseek-ai/dsh-client-modules'
import type { ClientModuleLoaderTarget, WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
import type { ClientModuleLoaderTarget, WebBootEntry, WebBootGraph } from '@deepseek-ai/dsh-client-modules/client'
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
interface AssembledPlugin extends WebBootEntry {
@@ -64,17 +64,16 @@ const BUNDLE_LAYERS = [
const bundleResolvers = BUNDLE_LAYERS.map(layer => createRequire(layer.manifest))
const webBundleResolver = bundleResolvers[1]
if (webBundleResolver === undefined) throw new Error('assembled boot: web bundle resolver missing')
const workspacePackageManifests = new Map(globSync('packages/*/*/package.json', { cwd: REPO_ROOT }).map((relative) => {
const path = join(REPO_ROOT, relative)
const pkg = JSON.parse(readFileSync(path, 'utf8')) as ClientPackageManifest
if (pkg.name === undefined) throw new Error(`assembled boot: workspace package has no name: ${path}`)
return [pkg.name, path]
}))
const appBoot = await import(pathToFileURL(webBundleResolver.resolve('@deepseek-ai/dsh-app-boot')).href) as unknown as BootComposition
function resolvePackageManifest(specifier: string): string | undefined {
for (const require of bundleResolvers) {
try {
return require.resolve(`${specifier}/package.json`)
} catch {
continue
}
}
return undefined
return workspacePackageManifests.get(specifier)
}
function resolveClientExport(packagePath: string, pkg: ClientPackageManifest): string {
@@ -121,13 +120,58 @@ function loadAssembledPlugins(): readonly AssembledPlugin[] {
const PLUGINS = loadAssembledPlugins()
const bundles = new Map(PLUGINS.map(plugin => [
plugin.url,
readFileSync(plugin.bundlePath, 'utf8'),
]))
const BOOTSTRAP_IDS = ['@deepseek-ai/dsh-client-modules'] as const
const BOOTSTRAP_URL = '/plugins/_batch/bootstrap/fx/client.js'
const APPLICATION_URL = '/plugins/_batch/application/fx/client.js'
/** Build the fixture graph after applying per-scenario package exclusions. */
function bootGraph(plugins: readonly AssembledPlugin[]): WebBootGraph {
const bootstrapEntries = plugins
.map(plugin => plugin.id)
.filter(id => BOOTSTRAP_IDS.includes(id as typeof BOOTSTRAP_IDS[number]))
const applicationEntries = plugins
.map(plugin => plugin.id)
.filter(id => !BOOTSTRAP_IDS.includes(id as typeof BOOTSTRAP_IDS[number]))
return {
rev: 'fx',
entries: plugins.map(({ bundlePath: _bundlePath, ...plugin }) => plugin),
batches: [
...(bootstrapEntries.length === 0 ? [] : [{
phase: 'bootstrap' as const,
url: BOOTSTRAP_URL,
rev: 'fx',
entries: bootstrapEntries,
}]),
...(applicationEntries.length === 0 ? [] : [{
phase: 'application' as const,
url: APPLICATION_URL,
rev: 'fx',
entries: applicationEntries,
}]),
],
}
}
/** Build individual and batch script bodies for one fixture composition. */
function bundleTable(graph: WebBootGraph, plugins: readonly AssembledPlugin[]): Map<string, string> {
const bundles = new Map(plugins.map(plugin => [
plugin.url,
readFileSync(plugin.bundlePath, 'utf8'),
]))
for (const batch of graph.batches) {
bundles.set(batch.url, batch.entries.map((id) => {
const plugin = plugins.find(candidate => candidate.id === id)
if (plugin === undefined) throw new Error(`assembled boot: batch names unknown plugin ${id}`)
const code = bundles.get(plugin.url)
if (code === undefined) throw new Error(`assembled boot: missing built bundle ${plugin.url}`)
return code
}).join('\n;\n'))
}
return bundles
}
interface FixtureWindow extends Window {
__DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
__DSH_BOOT__?: WebBootGraph
__ModuleLoader__?: ClientModuleLoaderTarget
}
@@ -201,16 +245,17 @@ export function mountAssembledApp(search = '?fixture', options: AssembledBootOpt
const root = document.createElement('div')
root.id = 'root'
document.body.appendChild(root)
win.__DSH_BOOT__ = { rev: 'fx', entries: plugins.map(({ bundlePath: _bundlePath, ...plugin }) => plugin) }
const graph = bootGraph(plugins)
const bundles = bundleTable(graph, plugins)
win.__DSH_BOOT__ = graph
const [facadeRow] = bootInjections(win.__DSH_BOOT__)
if (facadeRow?.kind !== 'script') throw new Error('missing injected ModuleLoader facade row')
;(0, eval)(facadeRow.text)
// Mirror the blocking Host-injected modules script before the Vite entry calls create().
const modules = plugins.find(candidate => candidate.id === '@deepseek-ai/dsh-client-modules')
if (modules === undefined) throw new Error('missing parser-preloaded fixture row @deepseek-ai/dsh-client-modules')
const modulesCode = bundles.get(modules.url)
if (modulesCode === undefined) throw new Error(`missing built bundle ${modules.url}`)
;(0, eval)(modulesCode)
// Mirror the blocking Host-injected bootstrap batch before the Vite entry calls create().
const bootstrapUrl = graph.batches.find(batch => batch.phase === 'bootstrap')?.url
const bootstrap = bootstrapUrl === undefined ? undefined : bundles.get(bootstrapUrl)
if (bootstrap === undefined) throw new Error('missing parser-preloaded fixture batch')
;(0, eval)(bootstrap)
act(() => {
const entry = new AppWebEntry(root, {
loadBundle: async (url) => {
+2 -2
View File
@@ -192,8 +192,8 @@ describe('web e2e: settings modal and General preferences', () => {
.toMatch(/ui-theme:\n\s+preference: dark/)
await page.keyboard.press('Escape')
// Hold real plugin bundles so the shell-owned loading page remains observable.
const pluginPattern = /\/plugins\/@deepseek-ai\/dsh-client-ui-theme\/client\.js(?:\?.*)?$/
// Hold the real application batch so the shell-owned loading page remains observable.
const pluginPattern = /\/plugins\/_batch\/application\/[a-f\d]{12}\/client\.js$/
let releaseBundles = (): void => {}
const bundlesReleased = new Promise<void>((resolve) => { releaseBundles = resolve })
await page.route(pluginPattern, async (route) => {
+70 -2
View File
@@ -19,7 +19,7 @@ import { spawn } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { createServer } from 'node:http'
import { createRequire } from 'node:module'
import { createRequire, SourceMap } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
@@ -203,6 +203,23 @@ async function waitForAssistantMarker(baseUrl: string, sessionId: string, marker
}).toBe(true)
}
/** Find one real source location through a served indexed map. */
function firstMappedSource(script: string, payload: ConstructorParameters<typeof SourceMap>[0]): string | undefined {
const consumer = new SourceMap(payload)
const lines = script.split('\n')
for (let line = 0; line < lines.length; line++) {
const lastColumn = Math.min(lines[line]!.length, 512)
for (let column = 0; column <= lastColumn; column++) {
const entry = consumer.findEntry(line, column)
if (!('originalSource' in entry) || typeof entry.originalSource !== 'string') continue
if (entry.originalSource.startsWith('/packages/') && entry.originalSource.includes('/src/')) {
return entry.originalSource
}
}
}
return undefined
}
/** Real-host smoke screenshot: evidence for the figma comparison, not a failure artifact. */
async function screen(page: Page, name: string): Promise<void> {
await page.screenshot({ path: join(REPO_ROOT, '.artifacts', `w5-${name}.png`) })
@@ -237,7 +254,7 @@ const notReady = UI_PLUGIN_DIRS.filter((dir) => {
if (notReady.length > 0) console.warn(`[smoke-real] skipped — client bundles not ready: ${notReady.join(', ')}`)
describe('dsh web keyless CLI smoke', () => {
it('listens on 127.0.0.1 by default', async () => {
it('serves a usable app from two immutable plugin batches', async () => {
requireDist()
const sessionsDir = mkdtempSync(join(tmpdir(), 'dsh-web-keyless-'))
const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
@@ -256,11 +273,62 @@ describe('dsh web keyless CLI smoke', () => {
stdio: ['ignore', 'pipe', 'pipe'],
},
)
let browser: Browser | undefined
try {
const readyUrl = await waitForReadyLine(child)
expect(readyUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/)
expect((await fetch(readyUrl)).status).toBe(200)
browser = await chromium.launch({ headless: true })
const page = await newEnglishPage(browser)
const pluginScripts: string[] = []
const cacheHeaders = new Map<string, string | undefined>()
// Chromium reports `preload as=script` as Script and reuses that same
// request when the matching script node executes; this count pins both.
page.on('request', (request) => {
const url = new URL(request.url())
if (request.resourceType() === 'script' && url.pathname.startsWith('/plugins/')) {
pluginScripts.push(url.pathname)
}
})
page.on('response', (response) => {
const path = new URL(response.url()).pathname
if (path.startsWith('/plugins/_batch/')) {
cacheHeaders.set(path, response.headers()['cache-control'])
}
})
await page.goto(readyUrl)
await page.getByRole('button', { name: 'New session', exact: true }).first().waitFor({ timeout: 30_000 })
const batchPaths = [...new Set(pluginScripts)].sort()
expect(batchPaths).toEqual([
expect.stringMatching(/^\/plugins\/_batch\/application\/[a-f\d]{12}\/client\.js$/),
expect.stringMatching(/^\/plugins\/_batch\/bootstrap\/[a-f\d]{12}\/client\.js$/),
])
expect([...cacheHeaders.values()]).toEqual([
'public, max-age=31536000, immutable',
'public, max-age=31536000, immutable',
])
for (const path of batchPaths) {
const [scriptResponse, mapResponse] = await Promise.all([
fetch(`${readyUrl}${path}`),
fetch(`${readyUrl}${path}.map`),
])
expect(scriptResponse.status).toBe(200)
expect(mapResponse.status).toBe(200)
const script = await scriptResponse.text()
const payload = await mapResponse.json() as ConstructorParameters<typeof SourceMap>[0]
const sections = (payload as unknown as {
sections: { map: { sources?: unknown[]; sourcesContent?: unknown[] } }[]
}).sections
expect(sections.every(section => (
Array.isArray(section.map.sources)
&& Array.isArray(section.map.sourcesContent)
&& section.map.sourcesContent.length === section.map.sources.length
&& section.map.sourcesContent.every(source => typeof source === 'string')
))).toBe(true)
expect(firstMappedSource(script, payload)).toMatch(/^\/packages\/.+\/src\//)
}
} finally {
await browser?.close()
const closed = child.exitCode === null
? new Promise<void>((resolveClose) => { child.once('close', () => { resolveClose() }) })
: Promise.resolve()
+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: 84d673669825495b7e22933da238daba24ec7b13
client-modules.zh.md: 7cc353007732188f06738a745b750161a11366a5
client-modules.md: 8dae05be292d8d5628c59b768d84dfd97dec1ef2
client-modules.zh.md: 3668a4a3958b3a5957f094c0f925edb120d9b8c6
+55 -8
View File
@@ -2,13 +2,13 @@
English | [中文](client-modules.zh.md)
The web plugin table: the Node half of the client module system in [dsh-client-modules](../../packages/client/modules), provided as `ctx.clientModules` (`ClientModuleRegistry`). It scans the host Loader's entries for packages declaring `dsh.client`, composes the `window.__DSH_BOOT__` entry graph, serves each bundle at `/plugins/<id>/client.js`, and answers every index-injection collection with the boot manifest rows — the four faces of one service. It is an optional capability of the web GUI stack, not part of the agent-loop spine, and it is a consumer of [dsh-host-webserver](../../packages/host/webserver): the carrier described in [web-server.md](web-server.md) supplies the prefix route and the `webserver/index-inject` event this service answers. The same package's browser half (`ctx.modules`, the lazy-CJS module table that fetches and materializes these bundles) is kernel machinery documented in the [package README](../../packages/client/modules/README.md), not here.
The web plugin table: the Node half of the client module system in [dsh-client-modules](../../packages/client/modules), provided as `ctx.clientModules` (`ClientModuleRegistry`). It scans the host Loader's entries for packages declaring `dsh.client`, composes the `window.__DSH_BOOT__` entry graph, serves content-addressed startup batches and individual HMR scripts under `/plugins`, and answers every index-injection collection with the boot protocol rows — the four faces of one service. It is an optional capability of the web GUI stack, not part of the agent-loop spine, and it is a consumer of [dsh-host-webserver](../../packages/host/webserver): the carrier described in [web-server.md](web-server.md) supplies the prefix route and the `webserver/index-inject` event this service answers. The same package's browser half (`ctx.modules`, the lazy-CJS module table that fetches and materializes these bundles) is kernel machinery documented in the [package README](../../packages/client/modules/README.md), not here.
Source: [`packages/client/modules/src/client/manifest.ts`](../../packages/client/modules/src/client/manifest.ts)
## The wire
The graph is the wire single source between the Node and browser halves: the host composes `WebBootEntry` rows from scanned packages, publishes the graph as a `global` injection row rendered ahead of later script rows (`globalThis["__DSH_BOOT__"]`, with `<` escaped so plugin-controlled strings cannot break out of the script element), and the shell parses it before booting anything. A page without a valid manifest cannot boot the browser-side parser throws loud on a missing or malformed graph.
The graph is the wire single source between the Node and browser halves. The host composes `WebBootEntry` rows and `WebBootBatch` descriptors from scanned packages, then contributes the registration facade, application preload, bootstrap script, and graph global to the structured index-injection table before the Vite entry. The `global` row renders as `globalThis["__DSH_BOOT__"]` with `<` escaped so plugin-controlled strings cannot break out of the script element. A page without a valid manifest cannot boot: the browser parser rejects malformed rows or batches, duplicate phase names, unknown members, and entries without exactly one initial batch.
```ts type-equiv
/**
@@ -22,9 +22,9 @@ The graph is the wire single source between the Node and browser halves: the hos
interface WebBootEntry {
/** Entry name == package name. */
id: string
/** Bundle endpoint, '/plugins/<id>/client.js?rev=<rev>'. */
/** Revisioned individual endpoint used by HMR. */
url: string
/** Bundle content hash (cache-busting consistency anchor). */
/** Opaque individual-artifact revision used for HMR cache busting. */
rev: string
/** Package-name dependency edges used for factory arrival and plugin composition. */
inject?: string[]
@@ -35,6 +35,25 @@ interface WebBootEntry {
}
```
```ts type-equiv
/** Initial script-delivery phase for one content-addressed bundle batch. */
type WebBootBatchPhase = 'bootstrap' | 'application'
```
```ts type-equiv
/** One initial-load script containing the factory registrations for several graph rows. */
interface WebBootBatch {
/** Parser-blocking bootstrap or preloaded application delivery. */
phase: WebBootBatchPhase
/** Content-addressed batch script endpoint. */
url: string
/** Hash over the batch script and indexed source map. */
rev: string
/** Graph entry ids whose factories the script registers, in execution order. */
entries: string[]
}
```
```ts type-equiv
/** The composed client entry graph the host injects as `window.__DSH_BOOT__`. */
interface WebBootGraph {
@@ -46,10 +65,12 @@ interface WebBootGraph {
* unrelated and remains owned by fiber service waiting.
*/
entries: WebBootEntry[]
/** Initial-load batches; every entry belongs to exactly one batch. */
batches: WebBootBatch[]
}
```
Each row's `rev` is the bundle's content hash and rides the URL as a cache-busting query; the graph `rev` hashes the composed rows, so any row change changes it. `immediately` marks the stage-one prefetch tier (fetch and execute during module-face boot, registration only); a lazy row is fetched on first import.
Each initial row's `rev` is an opaque process nonce plus sequence, so graph composition does not hash every individual artifact. After HMR observes a change, that row's revision becomes the hash of its new bundle and available source map. The bootstrap batch contains the modules row; the preloaded application batch contains every other row. Batch revisions hash the generated script and indexed source map, and the graph revision hashes both rows and batch descriptors. `immediately` marks the stage-one registration barrier; application rows share one script transport even when only some carry the mark.
## The scan
@@ -61,13 +82,29 @@ Package metadata — including the negative "not a client package" verdict — i
## The bundle route and index injection
`GET`/`HEAD /plugins/<id>/client.js` serves the registered bundle from disk with `no-cache` (the rev query, not HTTP caching, anchors consistency); other methods are 405. An unknown id — or a registered row whose bundle is unreadable because it has not been built yet — answers a loud 404, so no unreadable bundle appears as a successful JavaScript response. The injection rows carry the current graph on every index render, so a reload always boots against the live composition.
`GET`/`HEAD /plugins/_batch/<phase>/<rev>/client.js` serves the generated startup scripts, with indexed maps beside them. `GET`/`HEAD /plugins/<id>/client.js?rev=<rev>` serves the snapshotted individual artifact for HMR and stamps the same revision onto its map request. All versioned responses use long-lived immutable caching. Unknown paths, absent maps, missing revisions, and stale revisions answer 404 rather than serving current bytes under an old URL 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
`ClientModuleRegistry` (`ctx.clientModules`, defined in [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts)) exposes reads and the rebuild face; signatures are in the generated [service catalog](#ctxclientmodules--clientmoduleregistry). `graph()` returns the current composed graph (a stable object between changes) and `clientPath(id)` the bundle's absolute path. `rebuilt(id)` is the only entry point through which bundle content reaches the graph: it re-hashes the file, and only a real rev change recomposes the graph and notifies. `onRebuilt` fires per changed bundle with the new rev; `onGraphChanged` fires after any flush that recomposed the graph (row added or removed, or a rebuilt rev change) and is pull-model — listeners re-read `graph()`. Both notification paths contain listener exceptions so one throwing subscriber cannot skip later subscribers or kill whatever triggered the flush.
```ts type-equiv
/** Filesystem baseline captured before a client artifact snapshot is read. */
interface ClientArtifactBaseline {
/** Absolute path of the client bundle. */
readonly path: string
/** Bundle modification time in milliseconds. */
readonly mtimeMs: number
/** Bundle size in bytes. */
readonly size: number
/** Source-map modification time, or null when no map was observable. */
readonly mapMtimeMs: number | null
/** Source-map size in bytes, or null when no map was observable. */
readonly mapSize: number | null
}
```
In development, [dsh-client-hmr](../../packages/client/hmr/README.md) is the registry's watch driver: its node half stat-polls every graph row's bundle from a synchronously captured baseline, calls `rebuilt(id)` on change, resyncs its watch set through `onGraphChanged`, and broadcasts rev changes to the browser half over SSE. Production graphs omit the HMR row entirely; the module host itself never watches files.
`ClientModuleRegistry` (`ctx.clientModules`, defined in [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts)) exposes reads and the rebuild face; signatures are in the generated [service catalog](#ctxclientmodules--clientmoduleregistry). `graph()` returns the current composed graph (a stable object between changes), `clientPath(id)` returns the bundle's absolute path, and `artifactBaseline(id)` returns the bundle/map stat values captured before the current snapshot was read. `rebuilt(id)` is the only entry point through which changed bundle content reaches the graph: it re-hashes that artifact, and only a real rev change recomposes the graph and notifies. `onRebuilt` fires per changed bundle with the new rev; `onGraphChanged` fires after any flush that recomposed the graph (row added or removed, or a rebuilt rev change) and is pull-model — listeners re-read `graph()`. Both notification paths contain listener exceptions so one throwing subscriber cannot skip later subscribers or kill whatever triggered the flush.
In development, [dsh-client-hmr](../../packages/client/hmr/README.md) is the registry's watch driver: its node half stat-polls every graph row's bundle and optional map from the module host's pre-read baseline, calls `rebuilt(id)` only for a changed or dirty row, resyncs its watch set through `onGraphChanged`, and broadcasts rev changes to the browser half over SSE. Production graphs omit the HMR row entirely; the module host itself never watches files.
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
@@ -97,6 +134,16 @@ graph(): WebBootGraph
*/
clientPath(id: string): string | undefined
/**
* Filesystem baseline captured before an entry's current bytes were read.
* HMR compares it with the live files when installing a watch, so a write
* between startup composition and watch installation cannot disappear into
* the watcher's initial state.
* @param id - entry id (package name).
* @returns the path and baseline, or undefined for an unknown id.
*/
artifactBaseline(id: string): ClientArtifactBaseline | undefined
/**
* Re-hash one bundle (the HMR watch's registration hook — the only entry
* point through which bundle content changes reach the graph).
+55 -8
View File
@@ -2,13 +2,13 @@
[English](client-modules.md) | 中文
Web 插件表:[dsh-client-modules](../../packages/client/modules) 中 client 模块系统的 Node 半,以 `ctx.clientModules``ClientModuleRegistry`)形式提供。它扫描宿主 Loader 的 entry,找出声明了 `dsh.client` 的包,组合出 `window.__DSH_BOOT__` entry 图,在 `/plugins/<id>/client.js` 提供各个 bundle,并以启动 manifest(元数据清单)行回应每次 index 注入收集——这是同一个服务的四个面。它是 Web GUI 栈的一项可选能力,不属于 agent loop(智能体循环)主干,并且是 [dsh-host-webserver](../../packages/host/webserver) 的消费方:[web-server.md](web-server.zh.md) 所述的载体提供本服务注册的前缀路由与其回应的 `webserver/index-inject` 事件。同一个包的浏览器半(`ctx.modules`,即拉取并物化这些 bundle 的 lazy CJS 模块表)属于内核机件,记录在[包 README](../../packages/client/modules/README.zh.md)中,不在本页。
Web 插件表:[dsh-client-modules](../../packages/client/modules) 中 client 模块系统的 Node 半,以 `ctx.clientModules``ClientModuleRegistry`)形式提供。它扫描宿主 Loader 的 entry,找出声明了 `dsh.client` 的包,组合出 `window.__DSH_BOOT__` entry 图,在 `/plugins` 提供按内容寻址的启动批次与 HMR 独立脚本,并以启动协议行回应每次 index 注入收集——这是同一个服务的四个面。它是 Web GUI 栈的一项可选能力,不属于 agent loop(智能体循环)主干,并且是 [dsh-host-webserver](../../packages/host/webserver) 的消费方:[web-server.md](web-server.zh.md) 所述的载体提供本服务注册的前缀路由与其回应的 `webserver/index-inject` 事件。同一个包的浏览器半(`ctx.modules`,即拉取并物化这些 bundle 的 lazy CJS 模块表)属于内核机件,记录在[包 README](../../packages/client/modules/README.zh.md)中,不在本页。
源码:[`packages/client/modules/src/client/manifest.ts`](../../packages/client/modules/src/client/manifest.ts)
## wire
图是 Node 半与浏览器半之间协议层的唯一真源宿主从扫描到的包组合出 `WebBootEntry`,把图发布为一条 `global` 注入行、渲染在后续 script 行之前(`globalThis["__DSH_BOOT__"]`,其中 `<` 已转义,插件可控的字符串因此无法逃出 script 元素),壳则在启动任何东西之前先解析它。没有有效 manifest 的页面无法启动——浏览器侧的解析器在图缺失或畸形时大声抛错
图是 Node 半与浏览器半之间协议层的唯一真源宿主从扫描到的包组合出 `WebBootEntry``WebBootBatch` 描述,随后在 Vite entry 之前向结构化 index 注入表贡献 registration facade、application preload、bootstrap 脚本与图全局量。`global` 行渲染为 `globalThis["__DSH_BOOT__"]`,其中 `<` 已转义,插件可控的字符串因此无法逃出 script 元素。没有有效 manifest 的页面无法启动浏览器解析器会拒绝畸形 row 或批次、重复 phase 名、未知成员,以及未恰好归属一个初始批次的 entry
```ts type-equiv
/**
@@ -22,9 +22,9 @@ Web 插件表:[dsh-client-modules](../../packages/client/modules) 中 client
interface WebBootEntry {
/** Entry name == package name. */
id: string
/** Bundle endpoint, '/plugins/<id>/client.js?rev=<rev>'. */
/** Revisioned individual endpoint used by HMR. */
url: string
/** Bundle content hash (cache-busting consistency anchor). */
/** Opaque individual-artifact revision used for HMR cache busting. */
rev: string
/** Package-name dependency edges used for factory arrival and plugin composition. */
inject?: string[]
@@ -35,6 +35,25 @@ interface WebBootEntry {
}
```
```ts type-equiv
/** Initial script-delivery phase for one content-addressed bundle batch. */
type WebBootBatchPhase = 'bootstrap' | 'application'
```
```ts type-equiv
/** One initial-load script containing the factory registrations for several graph rows. */
interface WebBootBatch {
/** Parser-blocking bootstrap or preloaded application delivery. */
phase: WebBootBatchPhase
/** Content-addressed batch script endpoint. */
url: string
/** Hash over the batch script and indexed source map. */
rev: string
/** Graph entry ids whose factories the script registers, in execution order. */
entries: string[]
}
```
```ts type-equiv
/** The composed client entry graph the host injects as `window.__DSH_BOOT__`. */
interface WebBootGraph {
@@ -46,10 +65,12 @@ interface WebBootGraph {
* unrelated and remains owned by fiber service waiting.
*/
entries: WebBootEntry[]
/** Initial-load batches; every entry belongs to exactly one batch. */
batches: WebBootBatch[]
}
```
一行的 `rev` 是该 bundle 的内容哈希,并作为使缓存失效的查询参数附在 URL 上;图的 `rev` 对组合后的各行做哈希,因此任何一行的变化都会改变它。`immediately` 标记第一阶段预取档位(在模块面启动期间 fetch 并执行,只做登记);惰性行在首次 import 时才拉取
个初始 row 的 `rev` 都是不透明的进程 nonce 加序号,因此组合图时不会哈希每个独立产物。HMR 观察到变化后,该 row 的 revision 才改为新 bundle 及其可用 sourcemap 的哈希。Bootstrap 批次包含 modules row;预加载的 application 批次包含其他全部 row。批次 revision 对生成的脚本与 indexed sourcemap 求哈希,图 revision 则对 row 与批次描述一并求哈希。`immediately` 标记第一阶段的 registration barrier;即使只有部分 application row 携带该标记,它们仍共享一次脚本传输
## 扫描
@@ -61,13 +82,29 @@ interface WebBootGraph {
## bundle 路由与 index 注入
`GET`/`HEAD /plugins/<id>/client.js` 以 `no-cache` 从磁盘提供已注册的 bundle(锚定一致性的是 rev 查询参数,而非 HTTP 缓存);其他方法返回 405。未知 id——或已注册、但 bundle 因尚未构建而不可读的行——回应一个大声的 404,因此不可读 bundle 不会表现为成功的 JavaScript 响应。注入行在每次 index 渲染时携带当前图,因此刷新页面总是针对实时组合启动。
`GET``HEAD /plugins/_batch/<phase>/<rev>/client.js` 提供生成的启动脚本,并在相邻路径提供 indexed map。`GET``HEAD /plugins/<id>/client.js?rev=<rev>` 为 HMR 提供已快照的独立产物,并把同一 revision 写入其 map 请求。所有版本化响应都使用长期 immutable 缓存。未知路径、缺失 map、缺少 revision 及陈旧 revision 都返回 404,绝不在旧 URL 下提供当前字节,也不会让 SPA fallback 把 HTML 当作 JavaScript 返回;其他方法返回 405。注入行在每次 index 渲染时携带当前图,因此重新加载总是基于实时组合启动。
## 服务
`ClientModuleRegistry``ctx.clientModules`,定义于 [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts))暴露读取面与重建面;签名见生成的[服务目录](#ctxclientmodules--clientmoduleregistry)。`graph()` 返回当前组合出的图(两次变更之间是同一个稳定对象),`clientPath(id)` 返回该 bundle 的绝对路径。`rebuilt(id)` 是 bundle 内容到达图的唯一入口:它对文件重新哈希,只有 rev 真正变化才会重新组合图并发出通知。`onRebuilt` 按发生变化的 bundle 逐个触发并携带新 rev`onGraphChanged` 在任何一次重新组合了图的 flush 之后触发(行的增删,或 rebuilt 带来的 rev 变化),并采用拉取模型——监听器自行重读 `graph()`。两条通知路径都会兜住监听器异常,因此一个抛错的订阅者既不能让后续订阅者被跳过,也不能杀死触发这次 flush 的一方。
```ts type-equiv
/** Filesystem baseline captured before a client artifact snapshot is read. */
interface ClientArtifactBaseline {
/** Absolute path of the client bundle. */
readonly path: string
/** Bundle modification time in milliseconds. */
readonly mtimeMs: number
/** Bundle size in bytes. */
readonly size: number
/** Source-map modification time, or null when no map was observable. */
readonly mapMtimeMs: number | null
/** Source-map size in bytes, or null when no map was observable. */
readonly mapSize: number | null
}
```
开发环境下,[dsh-client-hmr](../../packages/client/hmr/README.zh.md) 是注册表的监视驱动:它的 Node 半从同步取得的基线出发,对图中每一行的 bundle stat 轮询,变化时调用 `rebuilt(id)`,经 `onGraphChanged` 重新同步监视集合,并通过 SSEServer-Sent Events)把 rev 变化广播给浏览器半。生产环境的图完全不含 HMR(热模块替换)行;模块宿主自身从不监视文件
`ClientModuleRegistry``ctx.clientModules`,定义于 [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts))暴露读取面与重建面;签名见生成的[服务目录](#ctxclientmodules--clientmoduleregistry)。`graph()` 返回当前组合出的图(两次变更之间是同一个稳定对象),`clientPath(id)` 返回 bundle 的绝对路径,`artifactBaseline(id)` 返回读取当前快照前捕获的 bundle/map stat 值。`rebuilt(id)` 是变化后的 bundle 内容到达图的唯一入口:它只对该产物重新哈希,只有 rev 真正变化才会重新组合图并发出通知。`onRebuilt` 按发生变化的 bundle 逐个触发并携带新 rev`onGraphChanged` 在任何一次重新组合了图的 flush 之后触发(行的增删,或 rebuilt 带来的 rev 变化),并采用拉取模型——监听器自行重读 `graph()`。两条通知路径都会兜住监听器异常,因此一个抛错的订阅者既不能让后续订阅者被跳过,也不能杀死触发这次 flush 的一方
开发环境下,[dsh-client-hmr](../../packages/client/hmr/README.zh.md) 是注册表的监视驱动:它的 Node 半从 module host 读文件前记录的基线出发,对图中每一行的 bundle 与可选 map 做 stat 轮询,只为变化或标脏的 row 调用 `rebuilt(id)`,经 `onGraphChanged` 重新同步监视集合,并通过 SSEServer-Sent Events)把 rev 变化广播给浏览器半。生产环境的图完全不含 HMR(热模块替换)行;module host 自身从不监视文件。
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
@@ -97,6 +134,16 @@ graph(): WebBootGraph
*/
clientPath(id: string): string | undefined
/**
* Filesystem baseline captured before an entry's current bytes were read.
* HMR compares it with the live files when installing a watch, so a write
* between startup composition and watch installation cannot disappear into
* the watcher's initial state.
* @param id - entry id (package name).
* @returns the path and baseline, or undefined for an unknown id.
*/
artifactBaseline(id: string): ClientArtifactBaseline | undefined
/**
* Re-hash one bundle (the HMR watch's registration hook — the only entry
* point through which bundle content changes reach the graph).
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/hmr/README.md
README.md: c355595dd53ddcb74be629a6d5e730c6c5fcebbf
README.zh.md: 6ed4d0e79cb755f84784823749994b448ff209b8
README.md: 089b3ba35780ccb7a24bc8fed10cc0a5353c9eb9
README.zh.md: e100dde3ced0f7272e9a75bc4d0a69f6beb4d4ee
+2 -2
View File
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Hot reload for script-loaded client plugins. The web bundle mounts the row unconditionally; without a rebuild watcher (`pnpm run dev:web`) rewriting client bundles, the poll observes no changes and the chain stays idle.
The browser half subscribes to the system SSE channel (`GET /plugins/events`) and reloads one plugin per `rebuilt` frame through a serialized queue. The sequence per frame — `invalidate`, `prefetch` (load and register the new bundle while the old fiber still serves), `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `<style data-plugin>` tags, `entry.refresh()` re-imports and remounts, `fiber.await()` rethrows startup failures loud. Dependents reload through cordis itself: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber cascades every dependent with zero client-side graph analysis. The node half detects rebuilds with one interval that stat-polls each graph bundle from a synchronous baseline, immediately re-hashes after adding a row, retains missing rows as dirty, and broadcasts only real rev changes; any tsdown watch process producing the bundle therefore triggers HMR with no builder→host channel.
The browser half subscribes to the system SSE channel (`GET /plugins/events`) and reloads one plugin per `rebuilt` frame through a serialized queue. The frame revision makes `invalidate` select that plugin's immutable individual URL instead of its initial batch; `prefetch` loads and registers the new factory while the old fiber still serves. The remaining sequence is `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `<style data-plugin>` tags, `entry.refresh()` re-imports and remounts, then `fiber.await()` rethrows startup failures loud. Dependents reload through cordis itself: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber cascades every dependent with zero client-side graph analysis. The node half detects rebuilds with one interval that stat-polls each graph bundle and optional source map from the module host's pre-read baseline. An unchanged startup row begins watching without a content read or hash; a changed row, or a dirty row after its artifact reappears, enters `rebuilt()`, and only real revision changes are broadcast. Any tsdown watch process producing the artifacts therefore triggers HMR with no builder→host channel.
## Model Experience
@@ -18,4 +18,4 @@ None; this package neither assembles nor sends a provider request.
- **Reload is coarse by design** — a fresh fiber and fresh components; React state inside the reloaded plugin is lost while the data layer (connection/runtime fibers, Session objects) is untouched. react-refresh-grade state preservation conflicts with "re-executing the bundle re-runs the factory" and is deliberately out.
- **No failure rollback** — a reload that fails leaves the entry FAILED and visible in the loader status projection; the previous bundle is not restored automatically.
- **Graph rev is not refreshed by rebuilt frames** — the stale rev is harmless because the bundle endpoint serves no-cache; only reconnect refreshes it.
- **The boot graph is not replaced by rebuilt frames** — each frame carries the individual-script revision needed for that reload; a page reload receives the recomposed batch graph.
+2 -2
View File
@@ -4,7 +4,7 @@
为通过脚本加载的客户端插件提供热重载。web 组合包无条件挂载该行;没有重建 watcher(`pnpm run dev:web`)改写客户端 bundle 时,轮询观察不到变化,链路保持空闲。
浏览器侧订阅系统 SSEServer-Sent Events)通道(`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行。每帧的顺序是:`invalidate``prefetch`旧 fiber 仍在服务时加载并注册新组合包)、`registry.delete`(在 fiber dispose(资源释放)之前执行:仅 dispose fiber 会触发 vendored Loader 的 self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签、通过 `entry.refresh()` 重新导入并挂载通过 `fiber.await()` 直接重新抛出启动失败。依赖方由 Cordis 自身重载:fiber 的激活 epoch 会串联其服务提供方的 uid,因此替换提供方 fiber 会级联所有依赖方,无需客户端图分析。node 侧使用一个 interval 检测重建:从同步基线开始 stat-poll 每个图组合包;新增一行后立即重新计算 hash;缺失行保持 dirty;只广播真实 rev 变更。因此,任何生成组合包的 tsdown watch 进程都能触发 HMR(热模块替换),无需 builder→host 通道。
浏览器侧订阅系统 SSEServer-Sent Events)通道(`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行。帧内 revision 会让 `invalidate` 选择该插件的不可变独立 URL,而不是初始批次;`prefetch`旧 fiber 仍在服务时加载并登记新 factory。其余顺序是:`registry.delete`(在 fiber dispose(资源释放)之前执行:仅 dispose fiber 会触发 vendored Loader 的 self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签、通过 `entry.refresh()` 重新导入并挂载,最后通过 `fiber.await()` 直接重新抛出启动失败。依赖方由 Cordis 自身重载:fiber 的激活 epoch 会串联其服务提供方的 uid,因此替换提供方 fiber 会级联所有依赖方,无需客户端图分析。node 侧使用一个 interval 检测重建,以 module host 读文件前记录的基线 stat-poll 每个图 bundle 及其可选 sourcemap。未变化的启动 row 无需读取内容或求哈希即可开始监视;只有发生变化的 row,或产物恢复后的 dirty row,才会进入 `rebuilt()`,并且只广播真实 revision 变更。因此,任何生成这些产物的 tsdown watch 进程都能触发 HMR(热模块替换),无需 builder→host 通道。
## 模型体验
@@ -18,4 +18,4 @@
- **重载有意保持粗粒度**:会创建全新的 fiber 和组件;重载插件中的 React 状态会丢失,数据层(连接 fiber、运行时 fiber 和 Session 对象)不受影响。react-refresh 级状态保留与「重新执行组合包会重新运行 factory」冲突,因此有意排除。
- **失败时不回滚**:失败的重载会使配置项处于 FAILED 状态,并在 loader 状态投影中显示;系统不会自动恢复先前组合包。
- **重建帧不会刷新图 rev**:陈旧 rev 无害,因为组合包端点以 no-cache 提供内容;只有重新连接时才会刷新
- **重建帧不会替换启动图**:每个帧都会携带本次重载所需的独立脚本 revision;页面重载时才接收重新组合的批次图
+15 -11
View File
@@ -64,7 +64,7 @@
import type { Context } from '@deepseek-ai/cordis'
import type { Entry, Loader } from '@deepseek-ai/cordis-plugin-loader'
import type { PluginsEventFrame } from '../events.ts'
import { EVENTS_ENDPOINT } from '../events.ts'
import { EVENTS_ENDPOINT, parsePluginsEventFrame } from '../events.ts'
export type { PluginsEventFrame } from '../events.ts'
export { EVENTS_ENDPOINT } from '../events.ts'
@@ -101,7 +101,7 @@ export function apply(ctx: Context): void {
const modLoader = ctx.modules
const loader: Loader = ctx.loader
async function reload(id: string): Promise<void> {
async function reload(id: string, rev: string): Promise<void> {
const entry = findEntry(loader, id)
if (entry === undefined) {
ctx.logger.warn(`client-hmr: rebuilt frame for unknown entry "${id}" (not in the loader tree)`)
@@ -112,7 +112,7 @@ export function apply(ctx: Context): void {
// async half while the old fiber still serves: script loading registers
// the fresh factory with zero side effects (lazy CJS — module bodies run
// at materialization, not execution).
modLoader.invalidate(id)
modLoader.invalidate(id, rev)
await modLoader.prefetch(id)
const oldFiber = entry.fiber
@@ -145,16 +145,15 @@ export function apply(ctx: Context): void {
const handle = (frame: PluginsEventFrame): void => {
switch (frame.type) {
case 'rebuilt':
queue = queue.then(() => reload(frame.id)).catch((error: unknown) => {
queue = queue.then(() => reload(frame.id, frame.rev)).catch((error: unknown) => {
ctx.logger.error(`client-hmr: reload of "${frame.id}" failed`)
ctx.logger.error(error)
})
break
case 'graph':
// Connect-time snapshot, unused. The loader's cached graph rev
// goes stale after rebuilds — harmless, since prefetch hits the
// network anyway (host serves bundles no-cache); graph rev refresh
// lands with the reconnect-handshake mechanism.
// Connect-time snapshot, unused. Each rebuilt frame carries the
// revision that selects the immutable individual script; the boot
// graph remains the initial-load record until a page reload.
break
default:
// Merge-extensible frame union: unknown frame types from newer hosts
@@ -166,15 +165,20 @@ export function apply(ctx: Context): void {
ctx.effect(() => {
const source = new EventSource(EVENTS_ENDPOINT)
source.addEventListener('message', (event: MessageEvent<string>) => {
let frame: PluginsEventFrame
let value: unknown
try {
frame = JSON.parse(event.data) as PluginsEventFrame
value = JSON.parse(event.data) as unknown
} catch {
// Wire boundary: a malformed dev-channel frame is dropped loudly.
ctx.logger.warn(`client-hmr: unparseable event frame: ${event.data}`)
return
}
handle(frame)
const parsed = parsePluginsEventFrame(value)
if (parsed.kind === 'invalid') {
ctx.logger.warn(`client-hmr: invalid event frame: ${event.data}`)
} else if (parsed.kind === 'frame') {
handle(parsed.frame)
}
})
return () => { source.close() }
}, 'client-hmr: event source')
+28
View File
@@ -12,5 +12,33 @@ export type PluginsEventFrame =
| { type: 'graph'; graph: WebBootGraph }
| { type: 'rebuilt'; id: string; rev: string }
/** Browser wire-parse result: known frame, forward-compatible unknown type, or malformed payload. */
export type PluginsEventParseResult =
| { kind: 'frame'; frame: PluginsEventFrame }
| { kind: 'unknown' }
| { kind: 'invalid' }
/**
* Validate one JSON-decoded SSE payload before it can mutate module state.
* @param value - Parsed JSON value from the EventSource message.
* @returns the known frame, an unknown-type marker, or an invalid marker.
*/
export function parsePluginsEventFrame(value: unknown): PluginsEventParseResult {
if (typeof value !== 'object' || value === null) return { kind: 'invalid' }
const record = value as Record<string, unknown>
switch (record.type) {
case 'rebuilt':
return typeof record.id === 'string' && typeof record.rev === 'string'
? { kind: 'frame', frame: { type: 'rebuilt', id: record.id, rev: record.rev } }
: { kind: 'invalid' }
case 'graph':
return typeof record.graph === 'object' && record.graph !== null
? { kind: 'frame', frame: { type: 'graph', graph: record.graph as WebBootGraph } }
: { kind: 'invalid' }
default:
return typeof record.type === 'string' ? { kind: 'unknown' } : { kind: 'invalid' }
}
}
/** System SSE endpoint pushing graph/rebuilt frames (wire protocol constant). */
export const EVENTS_ENDPOINT = '/plugins/events'
+50 -30
View File
@@ -1,7 +1,7 @@
/**
* HMR plugin, node half: the host end of the dev reload chain. One interval
* stat-polls every graph row's client bundle (polling by design: network
* mounts deliver no inotify events), reports content changes through
* stat-polls every graph row's client bundle and optional source map (polling
* by design: network mounts deliver no inotify events), reports changes through
* `clientModuleHost.rebuilt(id)`, and serves the `/plugins/events` SSE channel
* broadcasting graph/rebuilt frames to the browser half (src/client/).
* The web bundle mounts this row unconditionally: without a rebuild
@@ -13,7 +13,7 @@ import type { ServerResponse } from 'node:http'
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
// Empty type imports carry the clientModuleHost/webServer Context merges.
import type {} from '@deepseek-ai/dsh-client-modules'
import type { ClientArtifactBaseline } from '@deepseek-ai/dsh-client-modules'
import type {} from '@deepseek-ai/dsh-host-webserver'
import type { PluginsEventFrame } from './events.ts'
import { EVENTS_ENDPOINT } from './events.ts'
@@ -42,11 +42,30 @@ function sseData(frame: PluginsEventFrame): string {
return `data: ${JSON.stringify(frame)}\n\n`
}
interface WatchedBundle {
path: string
mtimeMs: number
size: number
dirty: boolean
type WatchedArtifactStat = Omit<ClientArtifactBaseline, 'path'>
type WatchedBundle = {
-readonly [K in keyof ClientArtifactBaseline]: ClientArtifactBaseline[K]
} & { dirty: boolean }
/** Snapshot the bundle plus its optional development source map. */
function artifactStat(path: string): WatchedArtifactStat {
const bundle = statSync(path)
try {
const map = statSync(`${path}.map`)
return { mtimeMs: bundle.mtimeMs, size: bundle.size, mapMtimeMs: map.mtimeMs, mapSize: map.size }
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
return { mtimeMs: bundle.mtimeMs, size: bundle.size, mapMtimeMs: null, mapSize: null }
}
}
/** Whether neither served artifact changed since the last successful re-hash. */
function sameArtifactStat(left: WatchedArtifactStat, right: WatchedArtifactStat): boolean {
return left.mtimeMs === right.mtimeMs
&& left.size === right.size
&& left.mapMtimeMs === right.mapMtimeMs
&& left.mapSize === right.mapSize
}
/**
@@ -61,10 +80,10 @@ export function apply(ctx: Context, config: Config): void {
// --- bundle watch: one HMR-owned stat poll ------------------------------
const watched = new Map<string, WatchedBundle>()
const rehash = (id: string, watch: WatchedBundle, current: { mtimeMs: number; size: number }): void => {
const rehash = (id: string, watch: WatchedBundle, current: WatchedArtifactStat): void => {
try {
// rebuilt() re-hashes; an unchanged hash stays silent (clientModuleHost
// fires onRebuilt only on a real rev change).
// rebuilt() replaces the opaque startup rev on its first call; later
// calls stay silent when the content hash is unchanged.
ctx.clientModules.rebuilt(id)
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
@@ -76,37 +95,38 @@ export function apply(ctx: Context, config: Config): void {
}
watch.mtimeMs = current.mtimeMs
watch.size = current.size
watch.mapMtimeMs = current.mapMtimeMs
watch.mapSize = current.mapSize
watch.dirty = false
}
const watchRow = (id: string, path: string): void => {
let baseline: { mtimeMs: number; size: number }
const watchRow = (id: string, baseline: ClientArtifactBaseline): void => {
const watch: WatchedBundle = { ...baseline, dirty: false }
watched.set(id, watch)
let current: WatchedArtifactStat
try {
baseline = statSync(path)
current = artifactStat(baseline.path)
} catch (error) {
watched.set(id, { path, mtimeMs: 0, size: 0, dirty: true })
watch.dirty = true
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') ctx.logger.warn(error)
return
}
const watch = { path, mtimeMs: baseline.mtimeMs, size: baseline.size, dirty: false }
watched.set(id, watch)
// The module host hashed before publishing the graph. Re-hash immediately
// after capturing this baseline so a write in between cannot become an
// already-current baseline paired with a stale graph rev.
rehash(id, watch, baseline)
// The module host captured its baseline before reading the bytes in the
// startup batch. Only a mismatch crosses into the content-hash path.
if (!sameArtifactStat(current, watch)) rehash(id, watch, current)
}
const pollWatches = (): void => {
for (const [id, watch] of watched) {
let current: { mtimeMs: number; size: number }
let current: WatchedArtifactStat
try {
current = statSync(watch.path)
current = artifactStat(watch.path)
} catch (error) {
watch.dirty = true
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') ctx.logger.warn(error)
continue
}
if (!watch.dirty && current.mtimeMs === watch.mtimeMs && current.size === watch.size) continue
if (!watch.dirty && sameArtifactStat(current, watch)) continue
// Stat-before-hash preserves a detectable older baseline for writes that
// land during hashing. Repeated stat changes heal a torn read.
rehash(id, watch, current)
@@ -116,17 +136,17 @@ export function apply(ctx: Context, config: Config): void {
// Diff the watch set against the current graph: drop watches for removed
// rows (or rows whose bundle path moved), add watches for new rows.
const syncWatches = (): void => {
const rows = new Map<string, string>()
const rows = new Map<string, ClientArtifactBaseline>()
for (const row of ctx.clientModules.graph().entries) {
const path = ctx.clientModules.clientPath(row.id)
if (path !== undefined) rows.set(row.id, path)
const watch = ctx.clientModules.artifactBaseline(row.id)
if (watch !== undefined) rows.set(row.id, watch)
}
for (const [id, watch] of watched) {
if (rows.get(id) === watch.path) continue
if (rows.get(id)?.path === watch.path) continue
watched.delete(id)
}
for (const [id, path] of rows) {
if (!watched.has(id)) watchRow(id, path)
for (const [id, watch] of rows) {
if (!watched.has(id)) watchRow(id, watch)
}
}
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { parsePluginsEventFrame } from '../src/events.ts'
describe('plugin event wire parser', () => {
it('accepts complete rebuilt and graph frames', () => {
expect(parsePluginsEventFrame({ type: 'rebuilt', id: 'plugin', rev: 'next' })).toEqual({
kind: 'frame',
frame: { type: 'rebuilt', id: 'plugin', rev: 'next' },
})
const graph = { rev: 'graph', entries: [], batches: [] }
expect(parsePluginsEventFrame({ type: 'graph', graph })).toEqual({
kind: 'frame',
frame: { type: 'graph', graph },
})
})
it('separates forward-compatible unknown types from malformed known frames', () => {
expect(parsePluginsEventFrame({ type: 'future', payload: true })).toEqual({ kind: 'unknown' })
for (const value of [null, [], {}, { type: 'rebuilt', id: 'plugin' }, { type: 'graph', graph: null }]) {
expect(parsePluginsEventFrame(value)).toEqual({ kind: 'invalid' })
}
})
})
@@ -7,7 +7,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { WebBootGraph, ClientModuleRegistry } from '@deepseek-ai/dsh-client-modules'
import type { ClientArtifactBaseline, ClientModuleRegistry, WebBootGraph } from '@deepseek-ai/dsh-client-modules'
import type { WebRoute, WebServer } from '@deepseek-ai/dsh-host-webserver'
import { apply, Config, EVENTS_ENDPOINT, inject } from '../src/index.ts'
@@ -29,10 +29,28 @@ interface FakeHostOptions {
rebuilt?: (id: string) => string | undefined
}
function artifactBaseline(path: string): ClientArtifactBaseline {
const bundle = statSync(path)
try {
const sourceMap = statSync(`${path}.map`)
return {
path,
mtimeMs: bundle.mtimeMs,
size: bundle.size,
mapMtimeMs: sourceMap.mtimeMs,
mapSize: sourceMap.size,
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
return { path, mtimeMs: bundle.mtimeMs, size: bundle.size, mapMtimeMs: null, mapSize: null }
}
}
function fakeClientModuleHost(rows: Map<string, string>, options: FakeHostOptions = {}): FakeHost {
const graphListeners = new Set<() => void>()
const rebuiltCalls: string[] = []
const fake: Pick<FakeHost, 'graph' | 'clientPath' | 'rebuilt' | 'onRebuilt' | 'onGraphChanged' | 'rebuiltCalls' | 'fireGraphChanged'> = {
const baselines = new Map([...rows].map(([id, path]) => [id, artifactBaseline(path)]))
const fake: Pick<FakeHost, 'graph' | 'artifactBaseline' | 'rebuilt' | 'onRebuilt' | 'onGraphChanged' | 'rebuiltCalls' | 'fireGraphChanged'> = {
rebuiltCalls,
fireGraphChanged: () => { for (const l of graphListeners) l() },
graph: (): WebBootGraph => {
@@ -40,9 +58,19 @@ function fakeClientModuleHost(rows: Map<string, string>, options: FakeHostOption
return {
rev: 'r',
entries: [...rows.keys()].map(id => ({ id, url: `/plugins/${id}/client.js?rev=r`, rev: 'r' })),
batches: [],
}
},
clientPath: id => rows.get(id),
artifactBaseline: (id) => {
const path = rows.get(id)
if (path === undefined) return undefined
let baseline = baselines.get(id)
if (baseline?.path !== path) {
baseline = artifactBaseline(path)
baselines.set(id, baseline)
}
return { ...baseline }
},
rebuilt: (id) => {
rebuiltCalls.push(id)
return options.rebuilt?.(id) ?? 'r2'
@@ -92,14 +120,18 @@ describe('hmr node half', () => {
expect(routes).toHaveLength(1)
expect(routes[0]).toMatchObject({ kind: 'exact', path: EVENTS_ENDPOINT })
expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-a'])
clientModuleHost.rebuiltCalls.length = 0
expect(clientModuleHost.rebuiltCalls).toEqual([])
// Nudge mtime past stat granularity so the poller sees a content signal.
await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
writeFileSync(bundle, 'v2-longer')
await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-a') }, { timeout: 3_000 })
clientModuleHost.rebuiltCalls.length = 0
await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
writeFileSync(`${bundle}.map`, '{"version":3}')
await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-a') }, { timeout: 3_000 })
await fiber.dispose()
expect(routes).toHaveLength(0)
// Watcher gone: further file changes report nothing.
@@ -121,8 +153,7 @@ describe('hmr node half', () => {
writeFileSync(late, 'v1')
rows.set('pkg-late', late)
clientModuleHost.fireGraphChanged()
expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-late'])
clientModuleHost.rebuiltCalls.length = 0
expect(clientModuleHost.rebuiltCalls).toEqual([])
await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
writeFileSync(late, 'v2-longer')
@@ -137,7 +168,7 @@ describe('hmr node half', () => {
await fiber.dispose()
})
it('rehashes after baseline capture so a construction-window write cannot become the baseline', async () => {
it('rehashes only a row changed between its startup snapshot and watch installation', async () => {
const bundle = join(dir, 'construction.js')
writeFileSync(bundle, 'v1')
let rewrite = true
@@ -145,9 +176,6 @@ describe('hmr node half', () => {
beforeGraphRead: () => {
if (!rewrite) return
rewrite = false
// The graph carries the hash from before this write. The old
// fs.watchFile registration asynchronously captured the new file as
// its first baseline and never requested a re-hash.
writeFileSync(bundle, 'v2-written-during-watch-construction')
},
})
@@ -184,11 +212,15 @@ describe('hmr node half', () => {
await fiber.dispose()
})
it('retains a dirty baseline when the immediate re-hash races a rename', async () => {
it('retains a dirty baseline when a catch-up re-hash races a rename', async () => {
const bundle = join(dir, 'rename.js')
writeFileSync(bundle, 'v1')
let first = true
const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]), {
beforeGraphRead: () => {
if (!first) return
writeFileSync(bundle, 'v2-written-during-watch-construction')
},
rebuilt: () => {
if (!first) return 'r2'
first = false
+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: eaf64599bc5fc5d9663d00c3b341764a4ccd38c8
README.zh.md: ea051338bb7837cb49f7a5ecd4cdad5b6b3ad71d
README.md: 2e8ed59614a30a1c737e57e5e5869bccf6bfb6db
README.zh.md: 982d4a444f691cb575b77d35283e39940b66b42a
+5 -4
View File
@@ -6,13 +6,13 @@ Client module system: the browser peer of Node's internal ESM loader, built as a
Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`window.__ModuleLoader__.load({id, factory})`); every module body side effect — CSS injection included — lives in the factory closure and runs at materialization (`factory(require)` → exports, memoized in `loadCache`), not at script execution. A factory that requires another registered-but-unmaterialized module materializes it recursively; graph composition places declared dynamic requests before their consumers, and require cycles throw because factory-form CJS cannot deliver partial exports. `<id>/client` and the bare id resolve to the same exports (a plugin bundle IS its package's client half).
The Host installs `window.__ModuleLoader__` before parser preloads run. Its queue-mode `load()` retains early registrations; `create()` materializes this package's factory with an external-rejecting bootstrap require and calls its `createClientModuleSystem` export. Construction caches those same exports as the modules row, switches the same facade to live registration, and drains the remaining queue. The bundle retains the resulting system in a module closure, so its later Cordis `apply()` provides the identical instance as `ctx.modules` without another page global.
The Host installs `window.__ModuleLoader__`, preloads the application batch, then executes the parser-blocking bootstrap batch. Queue-mode `load()` retains the modules registration; `create()` materializes this package's factory with an external-rejecting bootstrap require and calls its `createClientModuleSystem` export. Construction caches those same exports as the modules row and switches the same facade to live registration. The bundle retains the resulting system in a module closure, so its later Cordis `apply()` provides the identical instance as `ctx.modules` without another page global.
Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → exports; graph row (`window.__DSH_BOOT__`) → register its classic-script 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 (script load and factory registration only; concurrent calls share one in-flight task); `invalidate` drops a non-bootstrap factory and materialized record so the next prefetch/import reloads the script (the HMR hook).
Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → exports; graph row (`window.__DSH_BOOT__`) → register its initial-batch 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 a batch 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 individual script, so HMR reloads one plugin without executing the batch again.
The Node half scans enabled Loader entries for web `dsh.client` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, carries package-specific `dsh.client.external` requests, orders dynamic providers before consumers, and serves each bundle with its source map under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; 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 emits a bootstrap batch for the modules row and an application batch for every other row. Each batch has a content-addressed script and an indexed Source Map v3 file assembled from the available plugin maps. Initial individual revisions are opaque process nonces, so startup does not hash every plugin; HMR hashes only an artifact whose watcher reports a change. Individual revisioned scripts and maps remain available for HMR; every versioned response is immutable, and a revision mismatch returns 404 instead of serving newer bytes under an older URL. 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: shell-seeded React, Cordis, and static UI libraries plus parser-preloaded runtime. 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).
`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).
## Model Experience
@@ -26,3 +26,4 @@ None; this package neither assembles nor sends a provider request.
- **Flat module graph by design** — every bundle is one module node whose edges point only at table leaves; the interface (`loadCache`/`edges`/`invalidate`) already supports a general module graph, so the externalization granularity can change without an interface change.
- **No unload bookkeeping of its own** — style removal and fiber teardown ordering live with the HMR driver (`@deepseek-ai/dsh-client-hmr`); the loader only inventories owned style tag ids per record.
- **Snapshot delivery retains artifact bytes** — the Host holds each bundle, optional source map, revision-stamped individual response, and generated batch in memory; HMR additionally retains one prior batch generation. Memory scales as several copies of the composed client artifacts in exchange for immutable responses and one-generation race tolerance.
+5 -4
View File
@@ -6,13 +6,13 @@
惰性 CJS 模型(web2):执行插件 bundle 只会注册其 factory`window.__ModuleLoader__.load({id, factory})`);每个模块主体的副作用(包括 CSS 注入)都位于 factory 闭包中,在物化时运行(`factory(require)` → 导出表层,并在 `loadCache` 中记忆化),不会在脚本执行时运行。如果 factory 依赖另一个已注册但尚未物化的模块,系统会递归物化它;图组合会把声明的动态请求提供方放在消费者之前,而 require 循环会抛出异常,因为 factory 形式的 CJS 无法提供部分导出。`<id>/client` 与裸 id 指向同一表层(一个插件 bundle 就是其包的客户端侧)。
Host 会在 parser preload 运行前安装 `window.__ModuleLoader__`。其 queue 模式的 `load()` 保存提前到达的 registration`create()` 使用拒绝 external 的 bootstrap require 物化本包 factory,并调用其 `createClientModuleSystem` 导出。构造过程把同一组导出缓存为 modules row,把同一个 facade 切换到 live registration,再排空余下 queue。Bundle 通过模块闭包保留生成的系统,因此随后 Cordis `apply()` 能把同一实例提供为 `ctx.modules`,无需另一个页面全局变量。
Host 会安装 `window.__ModuleLoader__`、预加载 application 批次,再执行阻塞 parser 的 bootstrap 批次。Queue 模式的 `load()` 保存 modules registration`create()` 使用拒绝 external 的 bootstrap require 物化本包 factory,并调用其 `createClientModuleSystem` 导出。构造过程把同一组导出缓存为 modules row把同一个 facade 切换到 live registration。Bundle 通过模块闭包保留生成的系统,因此随后 Cordis `apply()` 能把同一实例提供为 `ctx.modules`,无需另一个页面全局变量。
解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 导出;模块图记录(`window.__DSH_BOOT__`)→ 登记其 classic-script factory;已登记 factory → 物化;其他情况一律抛出异常。这是构建时 bundle 纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含异步 graph-row 加载分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段到达钩子(只加载脚本并登记 factory;并发调用共享一个进行中的任务);`invalidate` 会丢弃非 bootstrap factory 与物化记录,使下一次 prefetch/import 重新加载脚本;它是 HMR(热模块替换)钩子
解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 导出;模块图记录(`window.__DSH_BOOT__`)→ 登记其初始批次 factory;已登记 factory → 物化;其他情况一律抛出异常。这是构建时 bundle 纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含异步 graph-row 加载分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段到达钩子;共享同一批次 URL 的 row 会共享一个进行中的脚本任务。`invalidate(id, rev)` 会丢弃非 bootstrap factory 与物化记录,并让该 row 改用带 revision 的独立脚本,因此 HMR(热模块替换)只重载一个插件,不会再次执行整批脚本
Node 侧会扫描已启用的 Loader 配置项以发现 web `dsh.client` 包,解析每个 `exports["./client"]`,把构建后的 bundle 哈希和包专属 `dsh.client.external` 请求写入启动图,把动态提供方排在消费者之前,并通过 `/plugins` 提供该文件及其 sourcemap。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费这构建后的客户端导出;缺失文件共享一条构建说明,随后以包/路径列表列出各项,而无关的文件系统错误仍是独立故障。
Node 侧会扫描已启用的 Loader 配置项以发现 web `dsh.client` 包,解析并快照每个 `exports["./client"]` 及其可用 sourcemap,携带包专属 `dsh.client.external` 请求,把动态提供方排在消费者之前。它为 modules row 生成 bootstrap 批次,为其余 row 生成 application 批次;每个批次都有按内容寻址的脚本,以及由现有插件 map 组合而成的 indexed Source Map v3 文件。初始独立 revision 使用不透明的进程 nonce,因此启动时不会哈希每个插件;HMR 只哈希 watcher 报告发生变化的产物。HMR 仍可访问带 revision 的独立脚本与 map;所有版本化响应都不可变,revision 不匹配时返回 404,绝不在旧 URL 下提供新字节。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费这构建后的客户端导出;缺失文件共享一条构建说明,随后以包/路径列表列出各项,而无关的文件系统错误仍是独立故障。
`dsh.client.external` 是统一基座之外的可选精确 specifier 请求列表。统一基座包括外壳播种的 React、Cordis 和静态 UI 库,以及由 HTML parser 预载的 runtime。请求由其命名的动态 package row 或精确静态表键回答;只有末尾 `/client` 会别名到 package row,并且不存在 provider 别名声明。纯类型 import 会被擦除,不产生请求。组合阶段会拒绝畸形请求、缺失提供方、自请求和同步请求环;import 与 prefetch 会在消费者物化前递归登记动态提供方。参见[共享模块与模块图](../AGENTS.md#shared-modules-and-the-module-graph)。
`dsh.client.external`外壳播种的 React、Cordis 和静态 UI 库这一统一基座之外的可选精确 specifier 请求列表。请求由其命名的动态 package row 或精确静态表键回答;只有末尾 `/client` 会别名到 package row,并且不存在 provider 别名声明。纯类型 import 会被擦除,不产生请求。组合阶段会拒绝畸形请求、缺失提供方、自请求和同步请求环;import 与 prefetch 会在消费者物化前递归登记动态提供方。参见[共享模块与模块图](../AGENTS.md#shared-modules-and-the-module-graph)。
## 模型体验
@@ -26,3 +26,4 @@ Node 侧会扫描已启用的 Loader 配置项以发现 web `dsh.client` 包,
- **有意采用扁平模块图**:每个 bundle 是一个模块节点,其边只指向表中的叶节点;接口(`loadCache`/`edges`/`invalidate`)已经支持通用模块图,因此可以改变 externalization 粒度而不更改接口。
- **自身不维护卸载记录**:样式移除与 fiber 拆卸顺序属于 HMR 驱动器(`@deepseek-ai/dsh-client-hmr`);loader 只在每条记录中登记其拥有的样式标签 id。
- **快照式提供会常驻产物字节**:Host 会在内存中保留每个 bundle、可选 sourcemap、带 revision 的独立响应及生成的批次;HMR 还会保留上一代批次。内存会随组合出的客户端产物增长为数份副本,以换取 immutable 响应和一代竞态容忍。
+78 -9
View File
@@ -50,9 +50,9 @@ declare module '@deepseek-ai/cordis' {
export interface WebBootEntry {
/** Entry name == package name. */
id: string
/** Bundle endpoint, '/plugins/<id>/client.js?rev=<rev>'. */
/** Revisioned individual endpoint used by HMR. */
url: string
/** Bundle content hash (cache-busting consistency anchor). */
/** Opaque individual-artifact revision used for HMR cache busting. */
rev: string
/** Package-name dependency edges used for factory arrival and plugin composition. */
inject?: string[]
@@ -62,6 +62,21 @@ export interface WebBootEntry {
external?: string[]
}
/** Initial script-delivery phase for one content-addressed bundle batch. */
export type WebBootBatchPhase = 'bootstrap' | 'application'
/** One initial-load script containing the factory registrations for several graph rows. */
export interface WebBootBatch {
/** Parser-blocking bootstrap or preloaded application delivery. */
phase: WebBootBatchPhase
/** Content-addressed batch script endpoint. */
url: string
/** Hash over the batch script and indexed source map. */
rev: string
/** Graph entry ids whose factories the script registers, in execution order. */
entries: string[]
}
/** The composed client entry graph the host injects as `window.__DSH_BOOT__`. */
export interface WebBootGraph {
/** Consistency anchor over the whole graph (content + bundle hashes). */
@@ -72,15 +87,19 @@ export interface WebBootGraph {
* unrelated and remains owned by fiber service waiting.
*/
entries: WebBootEntry[]
/** Initial-load batches; every entry belongs to exactly one batch. */
batches: WebBootBatch[]
}
/** The npm-package view of one boot row: what the module table needs to fetch the bundle. */
export interface BootModuleRow {
/** Entry name == package name (module-table key). */
id: string
/** Bundle endpoint, '/plugins/<id>/client.js?rev=<rev>'. */
/** Revisioned individual endpoint used after HMR invalidation. */
url: string
/** Bundle content hash. */
/** Content-addressed batch endpoint used before the first HMR invalidation. */
initialUrl: string
/** Opaque individual-artifact revision used after HMR invalidation. */
rev: string
/** Injected package rows whose factories arrive before this row materializes. */
inject: string[]
@@ -156,8 +175,12 @@ export function parseBootManifest(wire: unknown): BootManifest {
if (!Array.isArray(graph.entries)) {
throw new Error('client-modules: boot manifest entries must be an array')
}
const modules: BootModuleRow[] = []
if (!Array.isArray(graph.batches)) {
throw new Error('client-modules: boot manifest batches must be an array')
}
const moduleFields: Omit<BootModuleRow, 'initialUrl'>[] = []
const plugins: BootPluginRow[] = []
const seenEntryIds = new Set<string>()
for (const value of graph.entries as unknown[]) {
if (typeof value !== 'object' || value === null) {
throw new Error('client-modules: boot manifest entry is not an object')
@@ -167,13 +190,15 @@ export function parseBootManifest(wire: unknown): BootManifest {
if (typeof row.id !== 'string' || typeof row.url !== 'string' || typeof row.rev !== 'string') {
throw new Error(`client-modules: boot manifest entry ${where} must carry string id/url/rev`)
}
if (seenEntryIds.has(row.id)) throw new Error(`client-modules: duplicate graph entry "${row.id}"`)
seenEntryIds.add(row.id)
const subject = `boot manifest entry ${where}`
const inject = optionalStringArray(subject, 'inject', row.inject)
const external = optionalStringArray(subject, 'external', row.external)
if (row.immediately !== undefined && typeof row.immediately !== 'boolean') {
throw new Error(`client-modules: boot manifest entry ${where} immediately must be a boolean`)
}
modules.push({
moduleFields.push({
id: row.id,
url: row.url,
rev: row.rev,
@@ -186,6 +211,47 @@ export function parseBootManifest(wire: unknown): BootManifest {
immediately: row.immediately === true,
})
}
const entryIds = new Set(moduleFields.map(row => row.id))
const initialUrls = new Map<string, string>()
const phases = new Set<WebBootBatchPhase>()
for (const value of graph.batches as unknown[]) {
if (typeof value !== 'object' || value === null) {
throw new Error('client-modules: boot manifest batch is not an object')
}
const batch = value as Record<string, unknown>
const phase = batch.phase
if (phase !== 'bootstrap' && phase !== 'application') {
throw new Error(`client-modules: boot manifest batch phase must be "bootstrap" or "application", received ${JSON.stringify(phase)}`)
}
if (phases.has(phase)) {
throw new Error(`client-modules: boot manifest carries duplicate "${phase}" batches`)
}
phases.add(phase)
if (typeof batch.url !== 'string' || typeof batch.rev !== 'string') {
throw new Error(`client-modules: boot manifest ${phase} batch must carry string url/rev`)
}
const entries = optionalStringArray(`boot manifest ${phase} batch`, 'entries', batch.entries)
if (entries === undefined || entries.length === 0) {
throw new Error(`client-modules: boot manifest ${phase} batch entries must be a non-empty string array`)
}
for (const id of entries) {
if (!entryIds.has(id)) {
throw new Error(`client-modules: boot manifest ${phase} batch names unknown entry "${id}"`)
}
if (initialUrls.has(id)) {
throw new Error(`client-modules: boot manifest entry "${id}" belongs to more than one batch`)
}
initialUrls.set(id, batch.url)
}
}
const modules = moduleFields.map((row): BootModuleRow => {
const initialUrl = initialUrls.get(row.id)
if (initialUrl === undefined) {
throw new Error(`client-modules: boot manifest entry "${row.id}" belongs to no initial-load batch`)
}
return { ...row, initialUrl }
})
return { rev: graph.rev, modules, plugins }
}
@@ -286,11 +352,14 @@ export interface ClientModuleLoader {
prefetch(id: string): Promise<void>
/**
* Full reset of one non-bootstrap module: drop its registered factory and
* materialized record so the next prefetch/import reloads it (the HMR
* invalidation hook). The bootstrap module remains materialized.
* materialized record so the next prefetch/import reloads its individual
* script rather than the initial batch. The bootstrap module remains
* materialized.
* @param id - entry name to invalidate.
* @param rev - New content revision from the HMR frame; omitted to reuse
* the graph revision or for page-local modules that register directly.
*/
invalidate(id: string): void
invalidate(id: string, rev?: string): void
}
/** Internal construction inputs assembled by the modules bundle's bootstrap export. */
+31 -9
View File
@@ -26,6 +26,17 @@ const defaultLoadBundle = (url: string): Promise<void> => new Promise((resolve,
document.head.append(el)
})
/** Replace the rev query while preserving absolute, protocol-relative, or path-relative form. */
function atRevision(url: string, rev: string): string {
const absolute = /^[A-Za-z][A-Za-z\d+.-]*:/.test(url)
const protocolRelative = url.startsWith('//')
const parsed = new URL(url, 'http://dsh.invalid')
parsed.searchParams.set('rev', rev)
if (absolute) return parsed.href
if (protocolRelative) return `//${parsed.host}${parsed.pathname}${parsed.search}${parsed.hash}`
return `${parsed.pathname}${parsed.search}${parsed.hash}`
}
/**
* Claim and inventory the <style> tags a factory injected during
* materialization: preset-emitted tags arrive pre-tagged with data-plugin;
@@ -58,8 +69,10 @@ export class ClientModuleSystem implements ClientModuleLoader {
private readonly seed: Map<string, unknown>
private readonly factories = new Map<string, ClientBundleRegistration['factory']>()
private readonly bootstrapIds = new Set<string>()
/** In-flight prefetch (script load) per id; concurrent callers share it. */
/** In-flight script transport per URL; every row in one batch shares it. */
private readonly pendingArrival = new Map<string, Promise<void>>()
/** Individual revisioned URL selected by HMR after invalidating one row. */
private readonly reloadUrls = new Map<string, string>()
/** Materialization re-entrancy guard: factory-form CJS cannot deliver partial exports, so a cycle is fatal. */
private readonly materializing = new Set<string>()
private readonly graphRows = new Map<string, BootModuleRow>()
@@ -111,17 +124,23 @@ export class ClientModuleSystem implements ClientModuleLoader {
/** Load one graph row so its factory is registered (idempotent per in-flight arrival). */
private arrive(row: BootModuleRow): Promise<void> {
const { id, url } = row
const pending = this.pendingArrival.get(id)
if (pending !== undefined) return pending
const { id } = row
if (this.loadCache.has(id) || this.factories.has(id)) return Promise.resolve()
const task = this.loadBundle(url).then(() => {
const reloadUrl = this.reloadUrls.get(id)
const url = reloadUrl ?? row.initialUrl
let transport = this.pendingArrival.get(url)
if (transport === undefined) {
transport = this.loadBundle(url).finally(() => { this.pendingArrival.delete(url) })
this.pendingArrival.set(url, transport)
}
return transport.then(() => {
if (!this.factories.has(id)) {
throw new Error(`client-modules: bundle ${url} loaded without registering "${id}" via __ModuleLoader__.load`)
}
}).finally(() => { this.pendingArrival.delete(id) })
this.pendingArrival.set(id, task)
return task
if (reloadUrl !== undefined && this.reloadUrls.get(id) === reloadUrl) {
this.reloadUrls.delete(id)
}
})
}
/** Register each injected package and unresolved dynamic request before its consumer. */
@@ -221,9 +240,12 @@ export class ClientModuleSystem implements ClientModuleLoader {
await this.arriveGraphRow(row)
}
invalidate(id: string): void {
invalidate(id: string, rev?: string): void {
const normalized = stripClientSuffix(id)
if (this.bootstrapIds.has(normalized)) return
const row = this.graphRows.get(normalized)
if (row !== undefined) this.reloadUrls.set(normalized, atRevision(row.url, rev ?? row.rev))
else this.reloadUrls.delete(normalized)
this.factories.delete(normalized)
this.loadCache.delete(normalized)
}
+305 -48
View File
@@ -2,11 +2,12 @@
* Node half of the client module system (`dsh.client` dual-face package): scans
* the host Loader's entries for packages declaring `dsh.client`, composes the
* `window.__DSH_BOOT__` entry graph (wire single source: {@link WebBootEntry}
* in `./client/manifest.ts`) in module-graph order, serves
* `/plugins/<id>/client.js` and its source map, contributes the boot manifest
* plus the parser-blocking bootstrap preloads to the webserver's index
* injection table, and provides the `clientModuleHost` service (the HMR node
* half's registration/notification face).
* in `./client/manifest.ts`) in module-graph order, serves two initial-load
* batches plus revisioned per-plugin HMR scripts and their source maps,
* contributes the registration facade, application preload, bootstrap script,
* and graph to the webserver's index injection table, and provides the
* `clientModuleHost` service (the HMR node half's registration/notification
* face).
*
* Scanning is incremental per package — there is no full-rescan code path.
* Every cordis `internal/plugin` emission (fiber construction/disposal) marks
@@ -21,9 +22,8 @@
* @module @deepseek-ai/dsh-client-modules
*/
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { createHash, randomBytes } from 'node:crypto'
import { readFileSync, statSync, type Stats } from 'node:fs'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
@@ -32,11 +32,11 @@ import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/cordis-plugin-loader'
import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
import { optionalStringArray, stripClientSuffix } from './client/manifest.ts'
import type { WebBootEntry, WebBootGraph } from './client/manifest.ts'
import type { WebBootBatch, WebBootBatchPhase, WebBootEntry, WebBootGraph } from './client/manifest.ts'
export { stripClientSuffix } from './client/manifest.ts'
export type {
BootManifest, BootModuleRow, BootPluginRow, WebBootEntry, WebBootGraph,
BootManifest, BootModuleRow, BootPluginRow, WebBootBatch, WebBootBatchPhase, WebBootEntry, WebBootGraph,
} from './client/manifest.ts'
declare module '@deepseek-ai/cordis' {
@@ -50,7 +50,7 @@ declare module '@deepseek-ai/cordis' {
interface DshClientDeclaration {
inject?: string[]
platform: string
/** Boot phase-one prefetch mark; absent means lazy (fetched on demand). */
/** Boot phase-one registration barrier; absent rows still ride the shared application batch. */
immediately?: boolean
/**
* Exact module-table requests beyond the implicit client baseline. Any
@@ -70,6 +70,20 @@ interface WebBootRowFields {
immediately: boolean
}
/** Filesystem baseline captured before a client artifact snapshot is read. */
export interface ClientArtifactBaseline {
/** Absolute path of the client bundle. */
readonly path: string
/** Bundle modification time in milliseconds. */
readonly mtimeMs: number
/** Bundle size in bytes. */
readonly size: number
/** Source-map modification time, or null when no map was observable. */
readonly mapMtimeMs: number | null
/** Source-map size in bytes, or null when no map was observable. */
readonly mapSize: number | null
}
/** Resolved package metadata for one `dsh.client` package (cached per name, never expires). */
interface PkgMeta extends WebBootRowFields {
clientPath: string
@@ -120,8 +134,29 @@ class ClientPackageCompositionError extends AggregateError {
interface WebPluginRecord {
entry: WebBootEntry
meta: PkgMeta
/** Exact build artifact included in the startup batches. */
bundle: Buffer
/** Pre-read filesystem baseline handed to the HMR watcher. */
baseline: ClientArtifactBaseline
/** Revision-stamped individual response used after HMR invalidation. */
individualBundle: Buffer
/** Optional parsed and original source map snapshot for immutable delivery. */
sourceMap?: { body: Buffer; parsed: Record<string, unknown> }
}
/** One generated initial-load response and its wire descriptor. */
interface BatchArtifact {
descriptor: WebBootBatch
script: Buffer
sourceMap?: Buffer
}
/** Versioned code is immutable; mismatched revisions are rejected instead of serving newer bytes. */
const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable'
/** Source-map trailer emitted by tsdown at the end of every client bundle. */
const SOURCE_MAP_TRAILER = /(?:\r?\n)?\/\/# sourceMappingURL=[^\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 {
if (value === undefined) return undefined
@@ -158,11 +193,118 @@ function clientExportOf(pkgName: string, exportsField: unknown): string | undefi
throw new Error(`client-modules: ${pkgName} exports["./client"] must be a string or an object with a string default`)
}
/** sha1 content hash shortened to 12 hex chars (bundle rev / graph rev). */
/** sha1 content hash shortened to 12 hex chars (batch / graph / rebuilt-artifact rev). */
function shortHash(input: string | Buffer): string {
return createHash('sha1').update(input).digest('hex').slice(0, 12)
}
/** Hash several response fields without allowing bytes to move across field boundaries. */
function framedHash(domain: string, parts: readonly Buffer[]): string {
const hash = createHash('sha1').update(domain).update('\0')
for (const part of parts) hash.update(`${String(part.byteLength)}:`).update(part)
return hash.digest('hex').slice(0, 12)
}
/** Hash every byte served after HMR observes one artifact change. */
function artifactRevision(bundle: Buffer, sourceMap: WebPluginRecord['sourceMap']): string {
return framedHash('individual', sourceMap === undefined ? [bundle] : [bundle, sourceMap.body])
}
/** 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`
}
/** Stamp an individual bundle's map request with the same immutable revision. */
function individualBundle(input: Buffer, rev: string, hasSourceMap: boolean): Buffer {
const source = withoutSourceMapTrailer(input)
return Buffer.from(hasSourceMap ? `${source}//# sourceMappingURL=client.js.map?rev=${rev}\n` : source)
}
/** Parse an optional source-map artifact; missing maps do not prevent plugin execution. */
function sourceMapSnapshot(clientPath: string): WebPluginRecord['sourceMap'] {
let body: Buffer
try {
body = readFileSync(`${clientPath}.map`)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
throw error
}
const value = JSON.parse(body.toString('utf8')) as unknown
const parsed = typeof value === 'object' && value !== null ? value as Record<string, unknown> : undefined
if (
parsed === undefined
|| parsed.version !== 3
|| !Array.isArray(parsed.sources)
|| parsed.sources.some(source => typeof source !== 'string')
|| !Array.isArray(parsed.names)
|| parsed.names.some(name => typeof name !== 'string')
|| typeof parsed.mappings !== 'string'
) {
throw new Error(`client-modules: ${clientPath}.map is not a regular Source Map v3 object`)
}
return { body, parsed }
}
/** Count generated lines while assembling indexed-map section offsets. */
function newlineCount(value: string): number {
let count = 0
for (const char of value) if (char === '\n') count += 1
return count
}
/** Resolve section sources against their original per-plugin map URL before relocation into a batch. */
function batchSectionMap(record: WebPluginRecord): Record<string, unknown> {
const original = record.sourceMap?.parsed
/* v8 ignore next -- callers add sections only for records with a source map. */
if (original === undefined) throw new Error(`client-modules: source map missing for ${record.entry.id}`)
const sourcePaths = original.sources as string[]
const sourceRoot = typeof original.sourceRoot === 'string' ? original.sourceRoot : ''
const base = new URL(`/plugins/${record.entry.id}/client.js.map`, 'http://dsh.invalid')
const relocated = sourcePaths.map((source) => {
const separator = sourceRoot !== '' && !sourceRoot.endsWith('/') && !source.startsWith('/') ? '/' : ''
const resolved = new URL(`${sourceRoot}${separator}${source}`, base)
return resolved.origin === base.origin
? `${resolved.pathname}${resolved.search}${resolved.hash}`
: resolved.href
})
const section: Record<string, unknown> = { ...original, sources: relocated }
delete section.sourceRoot
return section
}
/** Concatenate factory registrations and compose their maps as indexed sections. */
function buildBatch(phase: WebBootBatchPhase, records: readonly WebPluginRecord[]): BatchArtifact {
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: batchSectionMap(record) })
}
const bundle = `${withoutSourceMapTrailer(record.bundle)};\n`
source += bundle
line += newlineCount(bundle)
}
const sourceMap = sections.length === 0
? undefined
: Buffer.from(`${JSON.stringify({ version: 3, file: 'client.js', sections })}\n`)
if (sourceMap !== undefined) source += '//# sourceMappingURL=client.js.map\n'
const script = Buffer.from(source)
const rev = framedHash('batch', sourceMap === undefined ? [script] : [script, sourceMap])
return {
descriptor: {
phase,
url: `/plugins/_batch/${phase}/${rev}/client.js`,
rev,
entries: records.map(record => record.entry.id),
},
script,
...(sourceMap === undefined ? {} : { sourceMap }),
}
}
/** Graph row for one bundle rev (url carries the rev as its cache-busting query). */
function graphRow(id: string, rev: string, fields: WebBootRowFields): WebBootEntry {
return {
@@ -222,13 +364,13 @@ export function orderByModuleGraph(entries: readonly WebBootEntry[]): WebBootEnt
/** Bootstrap package whose ordinary client bundle supplies the module-system implementation. */
const CLIENT_MODULES_ID = '@deepseek-ai/dsh-client-modules'
/** Ordinary dynamic bundles the HTML parser executes before the Vite shell. */
/** Dynamic bundles grouped into the parser bootstrap batch before the Vite shell. */
const PARSER_PRELOAD_IDS = [CLIENT_MODULES_ID] as const
/**
* The boot protocol as index injection rows. The inline registration queue
* precedes the blocking classic script for modules' ordinary `lib/client.js`
* artifact. Its `create()` method materializes the modules
* precedes the application-batch preload and the blocking bootstrap batch. Its
* `create()` method materializes the modules
* bundle, delegates construction to that bundle, and leaves the same facade
* in live-registration mode. The graph global follows before the shell reads
* it.
@@ -259,14 +401,17 @@ window.__ModuleLoader__={
}
}
})()`
const preload = PARSER_PRELOAD_IDS.map(id => graph.entries.find(entry => entry.id === id))
.filter((entry): entry is WebBootEntry => entry !== undefined)
.map((entry): IndexInjection => ({ kind: 'script-src', placement: 'head', src: entry.url }))
return [
{ kind: 'script', placement: 'head', text: queue },
...preload,
{ kind: 'global', name: '__DSH_BOOT__', value: graph },
]
const bootstrap = graph.batches.find(batch => batch.phase === 'bootstrap')
const application = graph.batches.find(batch => batch.phase === 'application')
const rows: IndexInjection[] = [{ kind: 'script', placement: 'head', text: queue }]
if (application !== undefined) {
rows.push({ kind: 'script-preload', src: application.url })
}
if (bootstrap !== undefined) {
rows.push({ kind: 'script-src', placement: 'head', src: bootstrap.url })
}
rows.push({ kind: 'global', name: '__DSH_BOOT__', value: graph })
return rows
}
/**
@@ -288,6 +433,11 @@ export class ClientModuleRegistry extends Service {
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 batchResponses = new Map<string, { body: Buffer; contentType: string }>()
/** One prior graph generation covers a request racing the HMR recomposition that replaced its URL. */
private previousBatchResponses = new Map<string, { body: Buffer; contentType: string }>()
private flushQueued = false
private composed: WebBootGraph
@@ -359,6 +509,19 @@ export class ClientModuleRegistry extends Service {
return this.table.get(id)?.meta.clientPath
}
/**
* Filesystem baseline captured before an entry's current bytes were read.
* HMR compares it with the live files when installing a watch, so a write
* between startup composition and watch installation cannot disappear into
* the watcher's initial state.
* @param id - entry id (package name).
* @returns the path and baseline, or undefined for an unknown id.
*/
artifactBaseline(id: string): ClientArtifactBaseline | undefined {
const baseline = this.table.get(id)?.baseline
return baseline === undefined ? undefined : { ...baseline }
}
/**
* Re-hash one bundle (the HMR watch's registration hook — the only entry
* point through which bundle content changes reach the graph).
@@ -368,9 +531,17 @@ export class ClientModuleRegistry extends Service {
rebuilt(id: string): string | undefined {
const record = this.table.get(id)
if (record === undefined) return undefined
const rev = shortHash(readFileSync(record.meta.clientPath))
const baseline = this.captureArtifactBaseline(record.meta.clientPath)
const bundle = readFileSync(record.meta.clientPath)
const sourceMap = this.readSourceMapSnapshot(record.meta.clientPath)
const rev = artifactRevision(bundle, sourceMap)
record.baseline = baseline
if (rev === record.entry.rev) return rev
record.entry = graphRow(id, rev, record.meta)
record.bundle = bundle
record.individualBundle = individualBundle(bundle, rev, sourceMap !== undefined)
if (sourceMap === undefined) delete record.sourceMap
else record.sourceMap = sourceMap
this.composed = this.compose()
for (const notify of this.rebuildListeners) {
// Containment: rebuilt() runs inside the HMR watch callback — a
@@ -408,7 +579,35 @@ export class ClientModuleRegistry extends Service {
private compose(): WebBootGraph {
const entries = orderByModuleGraph([...this.table.values()].map(record => record.entry))
return { rev: shortHash(JSON.stringify(entries)), entries }
const bootstrap = PARSER_PRELOAD_IDS
.map(id => this.table.get(id))
.filter((record): record is WebPluginRecord => record !== undefined)
const bootstrapIds = new Set(bootstrap.map(record => record.entry.id))
const application = entries
.filter(entry => !bootstrapIds.has(entry.id))
.map(entry => this.table.get(entry.id))
.filter((record): record is WebPluginRecord => record !== undefined)
const artifacts: BatchArtifact[] = []
if (bootstrap.length > 0) artifacts.push(buildBatch('bootstrap', bootstrap))
if (application.length > 0) artifacts.push(buildBatch('application', application))
const batchResponses = new Map<string, { body: Buffer; contentType: string }>()
for (const artifact of artifacts) {
batchResponses.set(artifact.descriptor.url, {
body: artifact.script,
contentType: 'text/javascript; charset=utf-8',
})
if (artifact.sourceMap !== undefined) {
batchResponses.set(`${artifact.descriptor.url}.map`, {
body: artifact.sourceMap,
contentType: 'application/json; charset=utf-8',
})
}
}
this.previousBatchResponses = this.batchResponses
this.batchResponses = batchResponses
const batches = artifacts.map(artifact => artifact.descriptor)
return { rev: shortHash(JSON.stringify({ entries, batches })), entries, batches }
}
private notifyGraphChanged(): void {
@@ -459,22 +658,63 @@ export class ClientModuleRegistry extends Service {
return meta
}
/** Capture the bundle and optional-map stats before reading their bytes. */
private captureArtifactBaseline(clientPath: string): ClientArtifactBaseline {
const bundle = statSync(clientPath)
let sourceMap: Stats | undefined
try {
sourceMap = statSync(`${clientPath}.map`)
} catch {
// Optional map metadata only seeds HMR; the following map read reports
// malformed or inaccessible bytes and a later stat change self-heals.
}
return {
path: clientPath,
mtimeMs: bundle.mtimeMs,
size: bundle.size,
mapMtimeMs: sourceMap?.mtimeMs ?? null,
mapSize: sourceMap?.size ?? null,
}
}
/** Allocate an opaque initial row revision without inspecting artifact bytes. */
private allocateInitialRevision(): string {
return `${this.initialRevisionNonce}-${String(this.nextInitialRevision++)}`
}
/**
* Read the activation-time bundle revision.
* Read the activation-time bundle and optional source-map snapshots.
* @param pkgName - package that declares the client bundle.
* @param clientPath - absolute path of the built client artifact.
* @returns the bundle content's short hash for use as its revision.
* @returns the immutable bytes plus the pre-read filesystem baseline.
* @throws {MissingClientBundleError} when the read fails with `ENOENT`; other filesystem errors are rethrown unchanged.
*/
private initialBundleRevision(pkgName: string, clientPath: string): string {
private initialBundleSnapshot(pkgName: string, clientPath: string): {
bundle: Buffer
baseline: ClientArtifactBaseline
sourceMap?: WebPluginRecord['sourceMap']
} {
try {
return shortHash(readFileSync(clientPath))
const baseline = this.captureArtifactBaseline(clientPath)
const bundle = readFileSync(clientPath)
const sourceMap = this.readSourceMapSnapshot(clientPath)
return { bundle, baseline, ...(sourceMap === undefined ? {} : { sourceMap }) }
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
throw new MissingClientBundleError(pkgName, clientPath, error)
}
}
/** Treat a missing, torn, or malformed development map as an unmapped artifact revision. */
private readSourceMapSnapshot(clientPath: string): WebPluginRecord['sourceMap'] {
try {
return sourceMapSnapshot(clientPath)
} catch (error) {
this.ctx.logger.warn(error)
return undefined
}
}
/** Reconcile one entry name against the live loader entries. @returns whether the table changed. */
private processOne(entryName: string): boolean {
let qualifies = false
@@ -488,10 +728,18 @@ export class ClientModuleRegistry extends Service {
if (this.table.has(entryName)) return false
const meta = this.resolveMeta(entryName)
if (meta === null) return false
// The rev rides the row from here on: a fiber restart reuses the row (and
// its rev) untouched; only rebuilt() re-reads the bundle.
const rev = this.initialBundleRevision(entryName, meta.clientPath)
this.table.set(entryName, { entry: graphRow(entryName, rev, meta), meta })
// 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)
const rev = this.allocateInitialRevision()
this.table.set(entryName, {
entry: graphRow(entryName, rev, meta),
meta,
bundle: snapshot.bundle,
baseline: snapshot.baseline,
individualBundle: individualBundle(snapshot.bundle, rev, snapshot.sourceMap !== undefined),
...(snapshot.sourceMap === undefined ? {} : { sourceMap: snapshot.sourceMap }),
})
return true
}
@@ -523,14 +771,24 @@ export class ClientModuleRegistry extends Service {
this.notifyGraphChanged()
}
private readonly serveBundle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
private readonly serveBundle = (req: IncomingMessage, res: ServerResponse): void => {
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.writeHead(405)
res.end()
return
}
/* v8 ignore next -- `?? '/'` arm: node:http always sets url on server requests. */
const pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://x').pathname)
const requestUrl = new URL(req.url ?? '/', 'http://x')
const pathname = decodeURIComponent(requestUrl.pathname)
const batch = this.batchResponses.get(pathname) ?? this.previousBatchResponses.get(pathname)
if (batch !== undefined) {
res.writeHead(200, {
'content-type': batch.contentType,
'cache-control': IMMUTABLE_CACHE,
})
res.end(req.method === 'HEAD' ? undefined : batch.body)
return
}
// The id may contain a scope slash. Anything else under /plugins (including
// /plugins/events when the HMR row is absent) is an unknown resource.
const prefix = '/plugins/'
@@ -538,27 +796,26 @@ export class ClientModuleRegistry extends Service {
const bundleSuffix = '/client.js'
const isSourceMap = pathname.startsWith(prefix) && pathname.endsWith(mapSuffix)
const suffix = isSourceMap ? mapSuffix : bundleSuffix
const clientPath = pathname.startsWith(prefix) && pathname.endsWith(suffix)
? this.clientPath(pathname.slice(prefix.length, -suffix.length))
const id = pathname.startsWith(prefix) && pathname.endsWith(suffix)
? pathname.slice(prefix.length, -suffix.length)
: undefined
const path = clientPath === undefined ? undefined : `${clientPath}${isSourceMap ? '.map' : ''}`
if (path === undefined) {
const record = id === undefined ? undefined : this.table.get(id)
if (record === undefined || requestUrl.searchParams.get('rev') !== record.entry.rev) {
res.writeHead(404)
res.end()
return
}
try {
const body = await readFile(path)
res.writeHead(200, {
'content-type': isSourceMap ? 'application/json; charset=utf-8' : 'text/javascript; charset=utf-8',
'cache-control': 'no-cache',
})
res.end(body)
} catch {
// Registered but unreadable (bundle not built yet): loud 404 beats a silent SPA-fallback HTML page.
const body = isSourceMap ? record.sourceMap?.body : record.individualBundle
if (body === undefined) {
res.writeHead(404)
res.end()
return
}
res.writeHead(200, {
'content-type': isSourceMap ? 'application/json; charset=utf-8' : 'text/javascript; charset=utf-8',
'cache-control': IMMUTABLE_CACHE,
})
res.end(req.method === 'HEAD' ? undefined : body)
}
}
@@ -8,6 +8,8 @@ import {
} from '../src/client/index.ts'
const MODULES_ID = '@deepseek-ai/dsh-client-modules'
const BOOTSTRAP_URL = '/plugins/_batch/bootstrap/graph/client.js'
const APPLICATION_URL = '/plugins/_batch/application/graph/client.js'
const win = globalThis as DshWindow
const bootstrapExports = { apply, createClientModuleSystem }
@@ -20,7 +22,15 @@ afterEach(() => {
})
const row = (id: string, fields: Partial<BootModuleRow> = {}): BootModuleRow =>
({ id, url: `/plugins/${id}/client.js?rev=0`, rev: '0', inject: [], external: [], ...fields })
({
id,
url: `/plugins/${id}/client.js?rev=0`,
initialUrl: id === MODULES_ID ? BOOTSTRAP_URL : APPLICATION_URL,
rev: '0',
inject: [],
external: [],
...fields,
})
interface Bench {
loader: ClientModuleLoader
@@ -68,13 +78,37 @@ function bench(
if (opts.gated?.includes(url) === true) {
await new Promise<void>((resolve) => { gates.set(url, resolve) })
}
const id = /\/plugins\/(.+)\/client\.js/.exec(url)?.[1]
const factory = id === undefined ? undefined : bundles[id]
if (factory == null || id === undefined) return
win.__ModuleLoader__?.load({ id, factory })
const batchIds = url === BOOTSTRAP_URL
? entries.filter(entry => entry.initialUrl === BOOTSTRAP_URL).map(entry => entry.id)
: url === APPLICATION_URL
? entries.filter(entry => entry.initialUrl === APPLICATION_URL).map(entry => entry.id)
: undefined
const individualId = /\/plugins\/(.+)\/client\.js/.exec(url)?.[1]
for (const id of batchIds ?? (individualId === undefined ? [] : [individualId])) {
const factory = bundles[id]
if (factory != null) win.__ModuleLoader__?.load({ id, factory })
}
}
const bootstrapEntries = entries.filter(entry => entry.initialUrl === BOOTSTRAP_URL).map(entry => entry.id)
const applicationEntries = entries.filter(entry => entry.initialUrl === APPLICATION_URL).map(entry => entry.id)
const batches = [
...(bootstrapEntries.length === 0 ? [] : [{
phase: 'bootstrap' as const, url: BOOTSTRAP_URL, rev: 'bootstrap', entries: bootstrapEntries,
}]),
...(applicationEntries.length === 0 ? [] : [{
phase: 'application' as const, url: APPLICATION_URL, rev: 'application', entries: applicationEntries,
}]),
]
const loader = target.create({
boot: { rev: 'graph', entries },
boot: {
rev: 'graph',
entries: entries.map(({ initialUrl: _initialUrl, inject, external, ...entry }) => ({
...entry,
...(inject.length === 0 ? {} : { inject }),
...(external.length === 0 ? {} : { external }),
})),
batches,
},
staticModules: opts.seed ?? {},
...(opts.defaultTransport === true ? {} : { loadBundle }),
})
@@ -104,7 +138,7 @@ describe('lazy CJS arrival', () => {
const ran: string[] = []
const b = bench([row('a')], { a: () => { ran.push('a'); return {} } })
await b.loader.prefetch('a')
expect(b.fetched).toEqual(['/plugins/a/client.js?rev=0'])
expect(b.fetched).toEqual([APPLICATION_URL])
expect(ran).toEqual([])
expect(b.loader.loadCache.has('a')).toBe(false)
})
@@ -139,10 +173,7 @@ describe('lazy CJS arrival', () => {
provider: { marker: string }
react: { marker: string }
}
expect(b.fetched).toEqual([
'/plugins/provider/client.js?rev=0',
'/plugins/consumer/client.js?rev=0',
])
expect(b.fetched).toEqual([APPLICATION_URL])
expect(exports.provider.marker).toBe('provider')
expect(exports.react.marker).toBe('react')
})
@@ -156,16 +187,13 @@ describe('lazy CJS arrival', () => {
provider: () => ({ marker: 'provider' }),
})
const exports = await b.loader.import('consumer', '', {}) as { provider: { marker: string } }
expect(b.fetched).toEqual([
'/plugins/provider/client.js?rev=0',
'/plugins/consumer/client.js?rev=0',
])
expect(b.fetched).toEqual([APPLICATION_URL])
expect(exports.provider.marker).toBe('provider')
})
it('concurrent callers share one in-flight arrival and materialize once', async () => {
const ran: string[] = []
const url = '/plugins/a/client.js?rev=0'
const url = APPLICATION_URL
const b = bench([row('a')], { a: () => { ran.push('a'); return { marker: 'a' } } }, { gated: [url] })
const first = b.loader.import('a', '', {})
const second = b.loader.import('a', '', {})
@@ -255,7 +283,7 @@ describe('bootstrap module', () => {
const exports = await b.loader.import('consumer', '', {}) as { dep: unknown }
expect(exports.dep).toBe(bootstrapExports)
expect(await b.loader.import(`${MODULES_ID}/client`, '', {})).toBe(bootstrapExports)
expect(b.fetched).toEqual(['/plugins/consumer/client.js?rev=0'])
expect(b.fetched).toEqual([APPLICATION_URL])
})
it('publishes the same closed-over system when the modules Cordis plugin activates', () => {
@@ -310,7 +338,7 @@ describe('failure modes', () => {
it('double boot is loud', () => {
const b = bench([])
const options: ClientModuleCreateOptions = {
boot: { rev: 'graph', entries: [] },
boot: { rev: 'graph', entries: [], batches: [] },
staticModules: {},
}
expect(() => b.target.create(options)).toThrow('create called after module-system boot')
@@ -325,10 +353,11 @@ describe('boot manifest wire', () => {
{ id: 'a', url: '/plugins/a/client.js', rev: '1', inject: ['b'] },
{ id: 'b', url: '/plugins/b/client.js', rev: '2', external: ['react'] },
],
batches: [{ phase: 'application', url: '/batch.js', rev: 'batch', entries: ['a', 'b'] }],
})
expect(manifest.modules).toEqual([
{ id: 'a', url: '/plugins/a/client.js', rev: '1', inject: ['b'], external: [] },
{ id: 'b', url: '/plugins/b/client.js', rev: '2', inject: [], external: ['react'] },
{ id: 'a', url: '/plugins/a/client.js', initialUrl: '/batch.js', rev: '1', inject: ['b'], external: [] },
{ id: 'b', url: '/plugins/b/client.js', initialUrl: '/batch.js', rev: '2', inject: [], external: ['react'] },
])
})
@@ -336,8 +365,66 @@ describe('boot manifest wire', () => {
expect(() => parseBootManifest({
rev: 'graph',
entries: [{ id: 'a', url: '/a', rev: '1', external: 'react' }],
batches: [{ phase: 'application', url: '/batch.js', rev: 'batch', entries: ['a'] }],
})).toThrow('client-modules: boot manifest entry "a" external must be a string array')
})
it('requires the batch table', () => {
expect(() => parseBootManifest({ rev: 'graph', entries: [] }))
.toThrow('client-modules: boot manifest batches must be an array')
})
it('rejects malformed and duplicate batch phases', () => {
const entry = { id: 'a', url: '/a.js', rev: '1' }
expect(() => parseBootManifest({ rev: 'graph', entries: [entry], batches: [null] }))
.toThrow('client-modules: boot manifest batch is not an object')
expect(() => parseBootManifest({
rev: 'graph', entries: [entry], batches: [{ phase: 'idle', url: '/b.js', rev: 'b', entries: ['a'] }],
})).toThrow('boot manifest batch phase must be "bootstrap" or "application"')
expect(() => parseBootManifest({
rev: 'graph',
entries: [entry],
batches: [
{ phase: 'application', url: '/b.js', rev: '1', entries: ['a'] },
{ phase: 'application', url: '/c.js', rev: '2', entries: ['a'] },
],
})).toThrow('boot manifest carries duplicate "application" batches')
})
it('requires complete batch fields and non-empty entries', () => {
const entry = { id: 'a', url: '/a.js', rev: '1' }
expect(() => parseBootManifest({
rev: 'graph', entries: [entry], batches: [{ phase: 'application', entries: ['a'] }],
})).toThrow('boot manifest application batch must carry string url/rev')
expect(() => parseBootManifest({
rev: 'graph', entries: [entry], batches: [{ phase: 'application', url: '/b.js', rev: 'b', entries: [] }],
})).toThrow('boot manifest application batch entries must be a non-empty string array')
})
it('requires a one-to-one batch assignment over graph entries', () => {
const entries = [
{ id: 'a', url: '/a.js', rev: '1' },
{ id: 'b', url: '/b.js', rev: '2' },
]
expect(() => parseBootManifest({
rev: 'graph',
entries,
batches: [{ phase: 'application', url: '/batch.js', rev: 'b', entries: ['ghost'] }],
})).toThrow('boot manifest application batch names unknown entry "ghost"')
expect(() => parseBootManifest({
rev: 'graph',
entries,
batches: [
{ phase: 'bootstrap', url: '/boot.js', rev: 'boot', entries: ['a'] },
{ phase: 'application', url: '/batch.js', rev: 'app', entries: ['a', 'b'] },
],
})).toThrow('boot manifest entry "a" belongs to more than one batch')
expect(() => parseBootManifest({
rev: 'graph',
entries,
batches: [{ phase: 'application', url: '/batch.js', rev: 'b', entries: ['a'] }],
})).toThrow('boot manifest entry "b" belongs to no initial-load batch')
})
})
describe('HMR reset', () => {
@@ -345,14 +432,42 @@ describe('HMR reset', () => {
let generation = 0
const b = bench([row('a')], { a: () => ({ generation: ++generation }) })
const first = await b.loader.import('a', '', {})
b.loader.invalidate('a')
b.loader.invalidate('a', '1')
expect(b.loader.loadCache.has('a')).toBe(false)
await b.loader.prefetch('a')
const second = await b.loader.import('a', '', {})
expect(b.fetched).toHaveLength(2)
expect(b.fetched).toEqual([APPLICATION_URL, '/plugins/a/client.js?rev=1'])
expect((first as { generation: number }).generation).toBe(1)
expect((second as { generation: number }).generation).toBe(2)
})
it('preserves an absolute individual endpoint when applying the rebuilt revision', async () => {
const b = bench([
row('a', { url: 'https://plugins.example.test/plugins/a/client.js?rev=0' }),
], { a: () => ({}) })
await b.loader.import('a', '', {})
b.loader.invalidate('a', 'next')
await b.loader.prefetch('a')
expect(b.fetched.at(-1)).toBe('https://plugins.example.test/plugins/a/client.js?rev=next')
})
it('preserves a protocol-relative individual endpoint when applying the rebuilt revision', async () => {
const b = bench([
row('a', { url: '//plugins.example.test/plugins/a/client.js?rev=0' }),
], { a: () => ({}) })
await b.loader.import('a', '', {})
b.loader.invalidate('a', 'next')
await b.loader.prefetch('a')
expect(b.fetched.at(-1)).toBe('//plugins.example.test/plugins/a/client.js?rev=next')
})
it('uses the current individual revision when a graph-row invalidation omits an override', async () => {
const b = bench([row('a')], { a: () => ({}) })
await b.loader.import('a', '', {})
b.loader.invalidate('a')
await b.loader.prefetch('a')
expect(b.fetched).toEqual([APPLICATION_URL, '/plugins/a/client.js?rev=0'])
})
})
describe('style claiming', () => {
@@ -394,7 +509,7 @@ describe('default transport seam', () => {
const script = nodes[0]
if (!(script instanceof HTMLScriptElement)) throw new Error('expected script node')
expect(script.async).toBe(true)
expect(script.getAttribute('src')).toBe('/plugins/dee/client.js?rev=0')
expect(script.getAttribute('src')).toBe(APPLICATION_URL)
queueMicrotask(() => {
win.__ModuleLoader__?.load({ id: 'dee', factory: () => ({ marker: 'via-script' }) })
script.dispatchEvent(new Event('load'))
@@ -415,7 +530,7 @@ describe('default transport seam', () => {
})
const b = bench([row('dee')], {}, { defaultTransport: true })
await expect(b.loader.prefetch('dee')).rejects.toThrow(
'bundle script /plugins/dee/client.js?rev=0 failed to load',
`bundle script ${APPLICATION_URL} failed to load`,
)
expect([...document.querySelectorAll('script')]).toEqual([])
})
@@ -1,7 +1,8 @@
/** Node-half composition diagnostics for package metadata and built client bundles. */
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { SourceMap } from 'node:module'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { pathToFileURL } from 'node:url'
@@ -15,6 +16,8 @@ import type { ClientModuleLoaderTarget, WebBootEntry, WebBootGraph } from '../sr
const MODULES_ID = '@deepseek-ai/dsh-client-modules'
const UI_RENDERER_ID = '@deepseek-ai/dsh-client-ui-renderer'
const BOOTSTRAP_URL = '/plugins/_batch/bootstrap/boot/client.js'
const APPLICATION_URL = '/plugins/_batch/application/app/client.js'
let root: string | undefined
@@ -81,6 +84,30 @@ function construct(packageNames: string[]): ClientModuleRegistry {
return constructWithRoute(packageNames).service
}
/** Invoke the registered plugin route and capture status, headers, and bytes. */
async function routeRequest(route: WebRoute, url: string, method = 'GET'): Promise<{
status: number
headers: Record<string, string> | undefined
body: Buffer
}> {
let status = 0
let headers: Record<string, string> | undefined
let body = Buffer.alloc(0)
const response = {
writeHead(nextStatus: number, nextHeaders?: Record<string, string>) {
status = nextStatus
headers = nextHeaders
return response
},
end(chunk?: Uint8Array) {
body = chunk === undefined ? Buffer.alloc(0) : Buffer.from(chunk)
return response
},
} as unknown as ServerResponse
await route.handler({ method, url } as IncomingMessage, response)
return { status, headers, body }
}
/** Execute the exact first inline script emitted by the Host boot rows. */
function injectedFacade(graph: WebBootGraph): { html: string; target: ClientModuleLoaderTarget } {
const html = renderIndexInjections(
@@ -101,6 +128,20 @@ const bootGraph = (): WebBootGraph => ({
{ id: MODULES_ID, url: '/plugins/modules.js?rev=m', rev: 'm' },
{ id: UI_RENDERER_ID, url: '/plugins/ui-renderer.js?rev=r', rev: 'r' },
],
batches: [
{
phase: 'bootstrap',
url: BOOTSTRAP_URL,
rev: 'boot',
entries: [MODULES_ID],
},
{
phase: 'application',
url: APPLICATION_URL,
rev: 'app',
entries: [UI_RENDERER_ID],
},
],
})
describe('HTML bootstrap facade', () => {
@@ -108,17 +149,25 @@ describe('HTML bootstrap facade', () => {
const graph = bootGraph()
const { html, target } = injectedFacade(graph)
const facadeAt = html.indexOf('window.__ModuleLoader__=')
const modulesAt = html.indexOf('<script src="/plugins/modules.js?rev=m"></script>')
const applicationAt = html.indexOf(
`<link rel="preload" as="script" href="${APPLICATION_URL}">`,
)
const bootstrapAt = html.indexOf(`<script src="${BOOTSTRAP_URL}"></script>`)
const graphAt = html.indexOf('globalThis["__DSH_BOOT__"] = ')
const entryAt = html.indexOf('<script type="module" src="/index.js"></script>')
expect(html).not.toContain('<script src="/plugins/ui-renderer.js?rev=r"></script>')
expect([facadeAt, modulesAt, graphAt, entryAt]).toEqual([...new Set([
facadeAt, modulesAt, graphAt, entryAt,
expect([facadeAt, applicationAt, bootstrapAt, graphAt, entryAt]).toEqual([...new Set([
facadeAt, applicationAt, bootstrapAt, graphAt, entryAt,
])].sort((a, b) => a - b))
target.load({ id: MODULES_ID, factory: () => modulesClient })
target.load({ id: UI_RENDERER_ID, factory: () => ({ marker: 'ui-renderer' }) })
const system = target.create({ boot: graph, staticModules: {} })
const system = target.create({
boot: graph,
staticModules: {},
loadBundle: async (url) => {
expect(url).toBe(APPLICATION_URL)
target.load({ id: UI_RENDERER_ID, factory: () => ({ marker: 'ui-renderer' }) })
},
})
expect(target.mode).toBe('live')
expect(target.pendingQueue).toEqual([])
@@ -209,40 +258,201 @@ describe('client bundle activation', () => {
expect(String(thrown)).not.toContain('pnpm run build')
})
it('omits a torn or malformed source map without blocking composition', async () => {
const packageName = '@fixture/malformed-source-map'
const clientPath = writePackage(packageName)
mkdirSync(dirname(clientPath), { recursive: true })
writeFileSync(clientPath, 'module.exports = {}\n')
writeFileSync(`${clientPath}.map`, '{')
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, `${torn.service.graph().batches[0]!.url}.map`)).status).toBe(404)
writeFileSync(`${clientPath}.map`, '{"version":3,"sources":[null]}\n')
expect(() => construct([packageName])).not.toThrow()
})
it('retains one prior immutable batch generation across rebuild recomposition', async () => {
const packageName = '@fixture/batch-rebuild-race'
const clientPath = writePackage(packageName)
mkdirSync(dirname(clientPath), { recursive: true })
writeFileSync(clientPath, 'module.exports = { generation: 1 }\n')
const { service, route } = constructWithRoute([packageName])
const first = service.graph().batches[0]!.url
const firstSize = service.artifactBaseline(packageName)!.size
writeFileSync(clientPath, 'module.exports = { generation: 200 }\n')
service.rebuilt(packageName)
const second = service.graph().batches[0]!.url
expect(second).not.toBe(first)
expect(service.artifactBaseline(packageName)!.size).toBeGreaterThan(firstSize)
expect((await routeRequest(route, first)).status).toBe(200)
expect((await routeRequest(route, second)).status).toBe(200)
writeFileSync(clientPath, 'module.exports = { generation: 3 }\n')
service.rebuilt(packageName)
const third = service.graph().batches[0]!.url
expect((await routeRequest(route, first)).status).toBe(404)
expect((await routeRequest(route, second)).status).toBe(200)
expect((await routeRequest(route, third)).status).toBe(200)
})
it('assigns opaque startup revisions instead of deriving them from artifact content', () => {
const firstName = '@fixture/startup-revision-first'
const secondName = '@fixture/startup-revision-second'
writeBuiltPackage(firstName, {})
writeBuiltPackage(secondName, {})
const service = construct([firstName, secondName])
const [first, second] = service.graph().entries
const firstMatch = /^(?<nonce>[a-f\d]{16})-(?<sequence>\d+)$/.exec(first!.rev)
const secondMatch = /^(?<nonce>[a-f\d]{16})-(?<sequence>\d+)$/.exec(second!.rev)
expect(firstMatch?.groups).toMatchObject({ sequence: '0' })
expect(secondMatch?.groups).toMatchObject({ nonce: firstMatch?.groups?.nonce, sequence: '1' })
const firstPath = service.clientPath(firstName)!
const firstStat = statSync(firstPath)
expect(service.artifactBaseline(firstName)).toEqual({
path: firstPath,
mtimeMs: firstStat.mtimeMs,
size: firstStat.size,
mapMtimeMs: null,
mapSize: null,
})
expect(service.artifactBaseline('@fixture/unknown')).toBeUndefined()
})
it('serves the source map beside a registered client bundle', async () => {
const packageName = '@fixture/source-map'
const clientPath = writePackage(packageName)
mkdirSync(dirname(clientPath), { recursive: true })
writeFileSync(clientPath, 'module.exports = {}\n')
const map = '{"version":3,"sources":["src/client/index.tsx"]}\n'
writeFileSync(clientPath, 'module.exports = {}\n//# sourceMappingURL=client.js.map')
const map = '{"version":3,"names":[],"mappings":"AAAA","sources":["../../../packages/client/demo/src/index.tsx","https://cdn.example.test/library.js"]}\n'
writeFileSync(`${clientPath}.map`, map)
const { route } = constructWithRoute([packageName])
let status = 0
let headers: Record<string, string> | undefined
let body = ''
const response = {
writeHead(nextStatus: number, nextHeaders?: Record<string, string>) {
status = nextStatus
headers = nextHeaders
return response
},
end(chunk?: Uint8Array) {
body = chunk === undefined ? '' : Buffer.from(chunk).toString('utf8')
return response
},
} as unknown as ServerResponse
await route.handler({
method: 'GET',
url: `/plugins/${packageName}/client.js.map`,
} as IncomingMessage, response)
expect(status).toBe(200)
expect(headers).toEqual({
const { service, route } = constructWithRoute([packageName])
const row = service.graph().entries[0]!
const individualScript = await routeRequest(route, row.url)
expect(individualScript.body.toString('utf8')).toContain(`sourceMappingURL=client.js.map?rev=${row.rev}`)
const individual = await routeRequest(route, row.url.replace('/client.js?', '/client.js.map?'))
expect(individual.status).toBe(200)
expect(individual.headers).toEqual({
'content-type': 'application/json; charset=utf-8',
'cache-control': 'no-cache',
'cache-control': 'public, max-age=31536000, immutable',
})
expect(body).toBe(map)
expect(individual.body.toString('utf8')).toBe(map)
const batch = service.graph().batches[0]!
expect(batch).toMatchObject({ phase: 'application', entries: [packageName] })
const batchScript = await routeRequest(route, batch.url)
expect(batchScript.status).toBe(200)
expect(batchScript.headers?.['cache-control']).toBe('public, max-age=31536000, immutable')
expect(batchScript.body.toString('utf8')).toContain('//# sourceMappingURL=client.js.map')
expect(batchScript.body.toString('utf8')).not.toContain('sourceMappingURL=client.js.map?rev=')
expect((await routeRequest(route, batch.url, 'HEAD')).body).toHaveLength(0)
expect((await routeRequest(route, batch.url, 'POST')).status).toBe(405)
const batchMap = await routeRequest(route, `${batch.url}.map`)
const parsedBatchMap = JSON.parse(batchMap.body.toString('utf8')) as unknown
const parsedIndividualMap = JSON.parse(map) as Record<string, unknown>
expect(parsedBatchMap).toMatchObject({
version: 3,
file: 'client.js',
sections: [{
offset: { line: 0, column: 0 },
map: {
...parsedIndividualMap,
sources: ['/packages/client/demo/src/index.tsx', 'https://cdn.example.test/library.js'],
},
}],
})
expect((await routeRequest(route, `${row.url}&stale=1`.replace(`rev=${row.rev}`, 'rev=stale'))).status).toBe(404)
writeFileSync(`${clientPath}.map`, '{"version":3,"names":[],"mappings":"AAAA","sources":["src/changed.tsx"]}\n')
const nextRev = service.rebuilt(packageName)
expect(nextRev).not.toBe(row.rev)
const nextMap = await routeRequest(route, `/plugins/${packageName}/client.js.map?rev=${String(nextRev)}`)
expect(JSON.parse(nextMap.body.toString('utf8'))).toMatchObject({ sources: ['src/changed.tsx'] })
})
it('applies sourceRoot before relocating absolute-looking section sources', async () => {
const packageName = '@fixture/source-root'
const clientPath = writePackage(packageName)
mkdirSync(dirname(clientPath), { recursive: true })
writeFileSync(clientPath, 'module.exports = {}\n')
writeFileSync(`${clientPath}.map`, JSON.stringify({
version: 3,
names: [],
mappings: 'AAAA',
sourceRoot: '../root',
sources: ['/absolute.ts'],
}))
const { service, route } = constructWithRoute([packageName])
const response = await routeRequest(route, `${service.graph().batches[0]!.url}.map`)
const map = JSON.parse(response.body.toString('utf8')) as {
sections: { map: { sourceRoot?: string; sources: string[] } }[]
}
expect(map.sections[0]?.map).toMatchObject({
sources: ['/plugins/@fixture/root/absolute.ts'],
})
expect(map.sections[0]?.map).not.toHaveProperty('sourceRoot')
})
it('maps a non-zero second batch section through a standard source-map consumer', async () => {
const firstName = '@fixture/offset-first'
const secondName = '@fixture/offset-second'
const firstPath = writePackage(firstName)
const secondPath = writePackage(secondName)
for (const [path, source] of [
[firstPath, '../../../packages/demo/first.ts'],
[secondPath, '../../../packages/demo/second.ts'],
] as const) {
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, 'window.first = true\nwindow.second = true\n')
writeFileSync(`${path}.map`, JSON.stringify({
version: 3,
names: [],
mappings: 'AAAA',
sources: [source],
sourcesContent: ['export {}\n'],
}))
}
const { service, route } = constructWithRoute([firstName, secondName])
const response = await routeRequest(route, `${service.graph().batches[0]!.url}.map`)
const payload = JSON.parse(response.body.toString('utf8')) as ConstructorParameters<typeof SourceMap>[0]
const sections = (payload as unknown as {
sections: { offset: { line: number; column: number } }[]
}).sections
expect(sections.map(section => section.offset)).toEqual([
{ line: 0, column: 0 },
{ line: 3, column: 0 },
])
const consumer = new SourceMap(payload)
expect(consumer.findEntry(0, 0)).toMatchObject({ originalSource: '/packages/demo/first.ts' })
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 () => {
const unmappedName = '@fixture/unmapped-first'
const mappedName = '@fixture/mapped-second'
const unmappedPath = writePackage(unmappedName)
const mappedPath = writePackage(mappedName)
mkdirSync(dirname(unmappedPath), { recursive: true })
mkdirSync(dirname(mappedPath), { recursive: true })
writeFileSync(unmappedPath, 'window.unmapped = true\n')
writeFileSync(mappedPath, 'window.mapped = true\n')
writeFileSync(`${mappedPath}.map`, JSON.stringify({
version: 3,
names: [],
mappings: 'AAAA',
sources: ['../../../packages/demo/mapped.ts'],
sourcesContent: ['export {}\n'],
}))
const { service, route } = constructWithRoute([unmappedName, mappedName])
const response = await routeRequest(route, `${service.graph().batches[0]!.url}.map`)
const payload = JSON.parse(response.body.toString('utf8')) as ConstructorParameters<typeof SourceMap>[0]
const consumer = new SourceMap(payload)
expect(consumer.findEntry(2, 0)).toMatchObject({ originalSource: '/packages/demo/mapped.ts' })
})
})
+38 -14
View File
@@ -94,7 +94,8 @@ function browserSourcePath(source: string, sourcemapPath: string): string {
* earlier Host pass. A package-level tsdown.config.ts REPLACES the root
* workspace layout, so the lib half must be restated here dropping it leaves
* the package without lib/index.js and the host Loader cannot import its node
* half.
* half. The Client build consumes `lib/types` and chains those tsc maps, with
* original source content, into the standalone plugin map.
* @param id - plugin id (package name), stamped into the __ModuleLoader__.load
* handoff and onto the injected style tags.
* @param libEntry - node-half entries, spelled at the call site so the
@@ -267,6 +268,7 @@ function staticLinkedConfig(id: string, entry: string, outputName = basename(ent
// The shell compiles this artifact, so its map is the only path from a
// browser stack frame back to the TSX (tsc emits the lib/types half).
sourcemap: true,
outputOptions: { sourcemapExcludeSources: false },
plugins: [{
// Contract 1. `pre` because tsdown's own deps plugin would otherwise
// resolve and inline every specifier missing from the npm production
@@ -281,18 +283,7 @@ function staticLinkedConfig(id: string, entry: string, outputName = basename(ent
return isBareSpecifier(source) ? { id: source, external: true } : null
},
},
}, {
// Contract 3. Rolldown does not read the `//# sourceMappingURL` of its
// inputs, so each tsc map is handed over as that module's map and
// composed into the bundle map; without it frames stop at the emitted
// lib/types JavaScript instead of reaching the TSX.
name: 'dsh-tsc-sourcemap',
async load(id: string) {
if (!id.includes(TYPES_MARKER) || !id.endsWith('.js') || !existsSync(`${id}.map`)) return null
const code = await readFile(id, 'utf8')
return { code: code.replace(SOURCEMAP_COMMENT, ''), map: await readFile(`${id}.map`, 'utf8') }
},
}, {
}, tscSourceMapPlugin(), {
// Contract 4. The import survives verbatim and the sheet lands beside the
// JavaScript, so the shell's CSS Modules pipeline sees a real stylesheet.
name: 'dsh-css-asset',
@@ -495,7 +486,7 @@ function clientConfig(id: string, entry: string): UserConfig {
+ '(type-only imports are erased and never reach this gate)',
)
},
}, {
}, tscSourceMapPlugin(), {
name: 'dsh-css-modules-inline',
resolveId(source: string, importer: string | undefined) {
if (!source.endsWith('.module.css')) return null
@@ -554,6 +545,7 @@ function clientConfig(id: string, entry: string): UserConfig {
}],
outputOptions: {
entryFileNames: 'client.js',
sourcemapExcludeSources: false,
// The map is served from /plugins/<scoped-package>/client.js.map. The
// browser resolves its local sources back into URLs that mirror the
// /packages/<group>/<package>/src directories; sourcesContent keeps them usable
@@ -566,6 +558,38 @@ function clientConfig(id: string, entry: string): UserConfig {
}
}
/** Chain tsc's emitted maps into any Client bundle that consumes `lib/types`. */
function tscSourceMapPlugin() {
return {
name: 'dsh-tsc-sourcemap',
async load(id: string) {
if (!id.includes(TYPES_MARKER) || !id.endsWith('.js') || !existsSync(`${id}.map`)) return null
const code = await readFile(id, 'utf8')
const mapPath = `${id}.map`
const map = JSON.parse(await readFile(mapPath, 'utf8')) as {
sourceRoot?: unknown
sources?: unknown
sourcesContent?: unknown
[key: string]: unknown
}
if (!Array.isArray(map.sources) || map.sources.some(source => typeof source !== 'string')) {
throw new Error(`client sourcemap: ${mapPath} has invalid sources`)
}
const sources = map.sources as string[]
if (
!Array.isArray(map.sourcesContent)
|| map.sourcesContent.length !== sources.length
|| map.sourcesContent.some(source => typeof source !== 'string')
) {
const sourceRoot = typeof map.sourceRoot === 'string' ? map.sourceRoot : ''
map.sourcesContent = await Promise.all(sources.map(async source =>
await readFile(resolvePath(dirname(mapPath), sourceRoot, source), 'utf8')))
}
return { code: code.replace(SOURCEMAP_COMMENT, ''), map }
},
}
}
/** Path segment separating a package's tsc output from the sources it was emitted from. */
const TYPES_MARKER = `${sep}lib${sep}types${sep}`
+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/web/README.md
README.md: 3208cb202dd9c101f1ab5f3936aac50fae35ab1c
README.zh.md: c6be7daf8a86660627095063590b063b80e14839
README.md: c95c5601b6e61d434e585bbf1887135fe177efb6
README.zh.md: 5335760011f2e801503011d49240e08a7638981e
+1 -1
View File
@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Web boot kernel: `new AppWebEntry(el, seams?).run()` mounts the client through two stages. The module stage calls the Host-installed `window.__ModuleLoader__.create()` with `window.__DSH_BOOT__`, the shell's static modules, and any test transport override; the facade returns the constructed module system and parsed manifest after adopting parser-preloaded registrations. This package then prefetches the `immediately` tier. The plugin stage mounts the vendored Cordis Loader, injects that module system through the Loader's `internal` interface, creates every graph entry uniformly, and waits for every fiber to become ACTIVE. It then hands the marked boot DOM to the dynamic UI renderer's `ctx.uiRenderer.mount(el)` operation; the renderer hydrates that DOM before switching to the complete UI. The Host owns the graph, parser preloads, and facade; AppWebEntry does not know the bootstrap package id or parse the wire format.
Web boot kernel: `new AppWebEntry(el, seams?).run()` mounts the client through two stages. The module stage calls the Host-installed `window.__ModuleLoader__.create()` with `window.__DSH_BOOT__`, the shell's static modules, and any test transport override; the facade returns the constructed module system and parsed manifest after adopting the parser-loaded bootstrap batch. This package then prefetches the `immediately` tier, whose shared application-batch URL executes once. The plugin stage mounts the vendored Cordis Loader, injects that module system through the Loader's `internal` interface, creates every graph entry uniformly, and waits for every fiber to become ACTIVE. It then hands the marked boot DOM to the dynamic UI renderer's `ctx.uiRenderer.mount(el)` operation; the renderer hydrates that DOM before switching to the complete UI. The Host owns the graph, batch preload, and facade; AppWebEntry does not know the bootstrap package id or parse the wire format.
The boot page uses plain DOM and local CSS, so client-bundle and plugin-activation failures remain visible. Its fallback fonts and colors match the theme tokens that arrive during loading. Fiber updates retain one spinner node and grow its CSS arc as entries first become active; hydration preserves that node and its animation phase until the application commit. React mounting, slot rendering, and application assembly live in [`ui-renderer`](../ui-renderer/README.md); [`ui-layout`](../ui-layout/README.md) owns the assembled browser-title projection. The modules bundle caches its own materialized exports and provides the closed-over system when its ordinary graph entry activates; Cordis service waiting makes graph-row creation order independent from that activation.
+1 -1
View File
@@ -2,7 +2,7 @@
[English](README.md) | 中文
Web 启动内核:`new AppWebEntry(el, seams?).run()` 分两个阶段挂载客户端。模块阶段调用 Host 安装的 `window.__ModuleLoader__.create()`,传入 `window.__DSH_BOOT__`、外壳静态模块以及可选测试传输覆盖;facade 接纳 parser 载的 registration 后返回构造好的模块系统与已解析 manifest。本包随后预取 `immediately` 层级。插件阶段挂载仓库内置的 Cordis Loader,通过 Loader 的 `internal` 接口注入该模块系统,统一创建全部图 entry,并等待每个 fiber 进入 ACTIVE。随后它把带标记的启动 DOM 交给动态 UI 渲染器的 `ctx.uiRenderer.mount(el)` 操作;渲染器先 hydrate 该 DOM,再切换到完整 UI。Graph、parser preload 与 facade 归 Host 所有;AppWebEntry 不感知 bootstrap package id,也不解析 wire 格式。
Web 启动内核:`new AppWebEntry(el, seams?).run()` 分两个阶段挂载客户端。模块阶段调用 Host 安装的 `window.__ModuleLoader__.create()`,传入 `window.__DSH_BOOT__`、外壳静态模块以及可选测试传输覆盖;facade 接纳 parser 已加载的 bootstrap 批次后返回构造好的模块系统与已解析 manifest。本包随后预取 `immediately` 层级,其共享的 application 批次 URL 只执行一次。插件阶段挂载仓库内置的 Cordis Loader,通过 Loader 的 `internal` 接口注入该模块系统,统一创建全部图 entry,并等待每个 fiber 进入 ACTIVE。随后它把带标记的启动 DOM 交给动态 UI 渲染器的 `ctx.uiRenderer.mount(el)` 操作;渲染器先 hydrate 该 DOM,再切换到完整 UI。Graph、批次 preload 与 facade 归 Host 所有;AppWebEntry 不感知 bootstrap package id,也不解析 wire 格式。
启动页只使用原生 DOM 与本地 CSS,因此客户端 bundle 或插件激活失败时仍能显示。其回退字体和颜色与加载期间到达的主题 token 一致。fiber 更新会保留同一个 spinner 节点,并在 entry 首次进入 active 时增长其 CSS 圆弧;hydrate 会继续保留该节点及其动画相位,直到应用提交。React 挂载、slot 渲染和应用组装位于 [`ui-renderer`](../ui-renderer/README.zh.md)[`ui-layout`](../ui-layout/README.zh.md) 拥有组装后的浏览器标题投影。Modules bundle 会缓存自身已物化导出,并在其普通图 entry 激活时提供闭包中的系统;Cordis service 等待使图 row 创建顺序不依赖该激活时点。
+41 -25
View File
@@ -80,7 +80,11 @@ describe('bootstrap failure rendering', () => {
await expectBootFailure(() => {
installFacade()
const duplicate = { id: 'duplicate', url: '/duplicate/client.js', rev: '1' }
win.__DSH_BOOT__ = { rev: 'graph', entries: [duplicate, duplicate] }
win.__DSH_BOOT__ = {
rev: 'graph',
entries: [duplicate, duplicate],
batches: [{ phase: 'application', url: '/batch.js', rev: 'batch', entries: ['duplicate'] }],
}
}, 'duplicate graph entry "duplicate"')
})
})
@@ -102,50 +106,54 @@ describe('plugin activation', () => {
{ id: 'provider', url: '/provider.js', rev: '1' },
{ id: 'renderer', url: '/renderer.js', rev: '1' },
]
win.__DSH_BOOT__ = { rev: 'graph', entries }
target.load({
id: 'runtime',
factory: require => ({
apply: () => {},
marker: (require(PROVIDER_CLIENT_ID) as { marker: string }).marker,
}),
})
const applicationUrl = '/application.js'
win.__DSH_BOOT__ = {
rev: 'graph',
entries,
batches: [{ phase: 'application', url: applicationUrl, rev: 'batch', entries: entries.map(row => row.id) }],
}
const loaded: string[] = []
const registrations = new Map<string, ClientBundleRegistration>([
['/consumer.js', {
const registrations: ClientBundleRegistration[] = [
{
id: 'consumer',
factory: require => ({
apply: () => {
expect((require(RUNTIME_CLIENT_ID) as { marker: string }).marker).toBe('provider')
},
}),
}],
['/provider.js', {
},
{
id: 'provider',
factory: () => ({ apply: () => {}, marker: 'provider' }),
}],
['/renderer.js', {
},
{
id: 'runtime',
factory: require => ({
apply: () => {},
marker: (require(PROVIDER_CLIENT_ID) as { marker: string }).marker,
}),
},
{
id: 'renderer',
factory: () => ({
apply: (ctx: Context) => {
ctx.reflect.provide('uiRenderer', { mount: () => () => {} })
},
}),
}],
])
},
]
transportGlobal.__DSH_TRANSPORT__ = {
loadBundle: async (url) => {
loaded.push(url)
const registration = registrations.get(url)
if (registration === undefined) throw new Error(`missing fixture registration ${url}`)
target.load(registration)
if (url !== applicationUrl) throw new Error(`missing fixture batch ${url}`)
for (const registration of registrations) target.load(registration)
},
}
const entry = new AppWebEntry(container)
await entry.run()
expect(loaded).toEqual(['/provider.js', '/consumer.js', '/renderer.js'])
expect(loaded).toEqual([applicationUrl])
await entry.dispose()
})
@@ -159,7 +167,16 @@ describe('plugin activation', () => {
{ id: MODULES_ID, url: '/modules.js', rev: '1' },
{ id: 'renderer', url: '/renderer.js', rev: '1' },
]
win.__DSH_BOOT__ = { rev: 'graph', entries }
win.__DSH_BOOT__ = {
rev: 'graph',
entries,
batches: [{
phase: 'application',
url: '/application.js',
rev: 'batch',
entries: entries.map(row => row.id),
}],
}
const registrations = new Map<string, ClientBundleRegistration>([
['/consumer.js', {
id: 'consumer',
@@ -188,9 +205,8 @@ describe('plugin activation', () => {
])
const entry = new AppWebEntry(container, {
loadBundle: async (url) => {
const registration = registrations.get(url)
if (registration === undefined) throw new Error(`missing fixture registration ${url}`)
target.load(registration)
if (url !== '/application.js') throw new Error(`missing fixture batch ${url}`)
for (const registration of registrations.values()) target.load(registration)
},
})
@@ -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: 3e9b4fffe0b97a97adf218aa12fd1f4342d3bc6c
README.zh.md: 2552c659d1b735b0cf28b9b0d0808276d31d0a2a
README.md: f8789f8e82005c15cd38cbe77832e486d4a44b9b
README.zh.md: e89ae7fd1e6b5d9ad9aa68edecfd92a81dcaf9b9
@@ -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. The tunnel exposes fetch-shaped transport, the API client, and `loadBundle` for the shell's boot seam.
- **`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.
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 URLboot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。隧道暴露 fetch 形传输API 客户端与壳启动缝隙用的 `loadBundle`
- **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URLboot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。脚本 preload 行只是提示,因此会被跳过:`/plugins` 资源只能经 tunnel 解析,`loadBundle` 会在首次需要时完成实际获取与执行。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。
@@ -34,6 +34,10 @@ export async function applyIndexInjections(
case 'script-src':
await loadScript(row.src)
break
case 'script-preload':
// The worker tunnel has no browser URL to warm without also executing
// the script; loadScript handles the real request when the row arrives.
break
case 'style': {
const el = document.createElement('style')
el.textContent = row.text
@@ -0,0 +1,21 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from 'vitest'
import { applyIndexInjections } from '../../src/client/apply-injections.ts'
afterEach(() => {
document.head.innerHTML = ''
document.body.innerHTML = ''
})
it('ignores script preload hints and executes script sources through the worker loader', async () => {
const loadScript = vi.fn(async () => {})
await applyIndexInjections([
{ kind: 'script-preload', src: '/plugins/preload.js' },
{ kind: 'script-src', placement: 'head', src: '/plugins/execute.js' },
], loadScript)
expect(loadScript).toHaveBeenCalledOnce()
expect(loadScript).toHaveBeenCalledWith('/plugins/execute.js')
expect(document.querySelector('link[rel="preload"]')).toBeNull()
})
@@ -519,6 +519,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
parameters: [{ name: 'id', description: 'entry id (package name).' }],
returns: 'the path, or undefined for an unknown id.',
},
{
signature: 'artifactBaseline(id: string): ClientArtifactBaseline | undefined',
description: 'Filesystem baseline captured before an entry\'s current bytes were read. HMR compares it with the live files when installing a watch, so a write between startup composition and watch installation cannot disappear into the watcher\'s initial state.',
parameters: [{ name: 'id', description: 'entry id (package name).' }],
returns: 'the path and baseline, or undefined for an unknown id.',
},
{
signature: 'rebuilt(id: string): string | undefined',
description: 'Re-hash one bundle (the HMR watch\'s registration hook — the only entry point through which bundle content changes reach the graph).',
@@ -3303,6 +3309,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'Branded',
declaration: 'export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n};',
},
{
name: 'ClientArtifactBaseline',
declaration: 'export interface ClientArtifactBaseline {\n readonly path: string;\n readonly mtimeMs: number;\n readonly size: number;\n readonly mapMtimeMs: number | null;\n readonly mapSize: number | null;\n}',
},
{
name: 'CodeBindingErrorClass',
declaration: 'export interface CodeBindingErrorClass {\n name: string;\n memberNameProperty: string;\n}',
@@ -3765,7 +3775,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'IndexInjection',
declaration: 'export type IndexInjection = {\n kind: \'global\';\n name: string;\n value: unknown;\n} | {\n kind: \'script\';\n placement: IndexInjectionPlacement;\n text: string;\n} | {\n kind: \'script-src\';\n placement: IndexInjectionPlacement;\n src: string;\n} | {\n kind: \'style\';\n text: string;\n} | {\n kind: \'html\';\n placement: IndexInjectionPlacement;\n html: string;\n};',
declaration: 'export type IndexInjection = {\n kind: \'global\';\n name: string;\n value: unknown;\n} | {\n kind: \'script\';\n placement: IndexInjectionPlacement;\n text: string;\n} | {\n kind: \'script-src\';\n placement: IndexInjectionPlacement;\n src: string;\n} | {\n kind: \'script-preload\';\n src: string;\n} | {\n kind: \'style\';\n text: string;\n} | {\n kind: \'html\';\n placement: IndexInjectionPlacement;\n html: string;\n};',
},
{
name: 'IndexInjectionPlacement',
@@ -5443,13 +5453,21 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'VerifiedWebhookDelivery',
declaration: 'export interface VerifiedWebhookDelivery<K extends string = string> {\n readonly kind: K;\n readonly source: WebhookSourceId;\n readonly deliveryId: WebhookDeliveryId;\n readonly event: WebhookEventOf<K>;\n readonly receivedAt: number;\n}',
},
{
name: 'WebBootBatch',
declaration: 'export interface WebBootBatch {\n phase: WebBootBatchPhase;\n url: string;\n rev: string;\n entries: string[];\n}',
},
{
name: 'WebBootBatchPhase',
declaration: 'export type WebBootBatchPhase = \'bootstrap\' | \'application\';',
},
{
name: 'WebBootEntry',
declaration: 'export interface WebBootEntry {\n id: string;\n url: string;\n rev: string;\n inject?: string[];\n immediately?: boolean;\n external?: string[];\n}',
},
{
name: 'WebBootGraph',
declaration: 'export interface WebBootGraph {\n rev: string;\n entries: WebBootEntry[];\n}',
declaration: 'export interface WebBootGraph {\n rev: string;\n entries: WebBootEntry[];\n batches: WebBootBatch[];\n}',
},
{
name: 'WebFetchBody',
+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/host/webserver/README.md
README.md: 0dc8f197f923c2dc4cb2d72ccb5b3a31f5384503
README.zh.md: d19e4a6be1df0c464d7ac61726e6bfb45a92c8a1
README.md: c6abc503222fc8bf60d4b6c940eeb1f7910cc9aa
README.zh.md: 430488869c98a86ff669e12acfaee86bae7aa8a3
+1 -1
View File
@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Web HTTP and upgrade-route registration plugin (default-exported `WebServer`, config `{host, port}`): a `node:http` server that listens on activation and provides `ctx.webServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `registerFallback(handler)` registers the one handler for requests that match no named route. A second registration throws; the SPA dist server [`dsh-host-frontend-static`](../frontend-static/README.md) is the shipped owner, and the server returns 404 while none is registered. Index startup inputs are structured rows: `collectIndexInjections()` gathers a fresh `IndexInjection` table over one `webserver/index-inject` emit per call, and `renderIndex(html)` renders the rows into an index.html body before applying the raw `tapIndex(transform)` transforms in registration order (`applyIndexTaps(html)`, the escape hatch for markup no row expresses); the fallback handler calls `renderIndex` on every index response, and a static deployment ships the same rows over its boot payload, rendering with the exported `renderIndexInjections`. `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP match order is fixed: exact over the whole table, then longest prefix, then the fallback handler. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics.
Web HTTP and upgrade-route registration plugin (default-exported `WebServer`, config `{host, port}`): a `node:http` server that listens on activation and provides `ctx.webServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `registerFallback(handler)` registers the one handler for requests that match no named route. A second registration throws; the SPA dist server [`dsh-host-frontend-static`](../frontend-static/README.md) is the shipped owner, and the server returns 404 while none is registered. Index startup inputs are structured rows: `collectIndexInjections()` gathers a fresh `IndexInjection` table over one `webserver/index-inject` emit per call, and `renderIndex(html)` renders the rows into an index.html body before applying the raw `tapIndex(transform)` transforms in registration order (`applyIndexTaps(html)`, the escape hatch for markup no row expresses); `script-preload` rows render advisory classic-script preload links. The fallback handler calls `renderIndex` on every index response, and a static deployment ships the same rows over its boot payload. `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP match order is fixed: exact over the whole table, then longest prefix, then the fallback handler. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics.
The package knows no harness concepts and serves no files: the `/api` HTTP bridge and downlink WebSockets are routes owned by the connection plugin, plugin bundles and the HMR event stream are routes owned by the modules/hmr plugins, and dist serving belongs to the fallback owner. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure). This server serves browsers only; Electron loads dist over `file://` and carries fetch over an IPC bridge. This package never prints; the URL line belongs to the shell.
+1 -1
View File
@@ -2,7 +2,7 @@
[English](README.md) | 中文
Web HTTP 与 upgrade route 注册插件(默认导出 `WebServer`,配置为 `{host, port}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.webServer``register(route)` 添加具名的 `exact``prefix` HTTP route`registerUpgrade(route)` 添加精确 pathname 的 upgrade route;同一张表内的重复路径会抛错,因为 route 模式是组合层约定,冲突即配置错误;两者返回的 disposer 都会移除注册。`registerFallback(handler)` 注册一个 handler,处理所有未被具名 route 命中的请求。第二次注册会抛错;随附的 SPA dist 服务器 [`dsh-host-frontend-static`](../frontend-static/README.zh.md) 是该 handler 的所有者,没有注册 handler 时服务器返回 404。index 的启动输入是结构化行:`collectIndexInjections()` 每次调用经一次 `webserver/index-inject` emit 现收一张全新的 `IndexInjection` 表,`renderIndex(html)` 先把行渲染进 index.html 响应体,再按注册顺序应用原始的 `tapIndex(transform)` 转换(`applyIndexTaps(html)`,行无法表达的标记的逃生口);fallback handler 在每次 index 响应时调用 `renderIndex`,静态部署则把同一批行经 boot 载荷下发,用导出的 `renderIndexInjections` 渲染`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后交给 fallback handler。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不影响请求处理。
Web HTTP 与 upgrade route 注册插件(默认导出 `WebServer`,配置为 `{host, port}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.webServer``register(route)` 添加具名的 `exact``prefix` HTTP route`registerUpgrade(route)` 添加精确 pathname 的 upgrade route;同一张表内的重复路径会抛错,因为 route 模式是组合层约定,冲突即配置错误;两者返回的 disposer 都会移除注册。`registerFallback(handler)` 注册一个 handler,处理所有未被具名 route 命中的请求。第二次注册会抛错;随附的 SPA dist 服务器 [`dsh-host-frontend-static`](../frontend-static/README.zh.md) 是该 handler 的所有者,没有注册 handler 时服务器返回 404。index 的启动输入是结构化行:`collectIndexInjections()` 每次调用经一次 `webserver/index-inject` emit 现收一张全新的 `IndexInjection` 表,`renderIndex(html)` 先把行渲染进 index.html 响应体,再按注册顺序应用原始的 `tapIndex(transform)` 转换(`applyIndexTaps(html)`,行无法表达的标记的逃生口);`script-preload` 行渲染为 classic script 的提示性预加载链接。fallback handler 在每次 index 响应时调用 `renderIndex`,静态部署则把同一批行经 boot 载荷下发。`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后交给 fallback handler。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不影响请求处理。
该包不了解任何 harness 概念,也不提供任何文件服务:`/api` HTTP 桥接与下行 WebSocket 是 connection 插件的 route,插件 bundle 与 HMR(热模块替换)事件流是 moduleshmr 插件的 routedist 服务则属于 fallback 持有者。upgrade handler 拥有协议握手与连接内容;webserver 只交付原始 socket 与 request。`host` 只接受 `127.0.0.1`(默认安全姿态)和 `0.0.0.0`(有意向网络开放)。该服务器只服务浏览器;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch。该包从不打印内容;URL 行属于 shell。
@@ -23,6 +23,8 @@ export type IndexInjection =
* loader resolves worker-only URLs such as `/plugins/...`).
*/
| { kind: 'script-src'; placement: IndexInjectionPlacement; src: string }
/** Advisory preload for an external classic script; static workers may ignore it. */
| { kind: 'script-preload'; src: string }
/** A `<style>` element in the head. `text` must not contain `</style`, which would close the element early. */
| { kind: 'style'; text: string }
/** Raw markup fragment. */
@@ -57,6 +59,8 @@ function renderRow(row: IndexInjection): { placement: IndexInjectionPlacement; m
return { placement: row.placement, markup: `<script>${row.text}</script>` }
case 'script-src':
return { placement: row.placement, markup: `<script src="${escapeHtmlAttribute(row.src)}"></script>` }
case 'script-preload':
return { placement: 'head', markup: `<link rel="preload" as="script" href="${escapeHtmlAttribute(row.src)}">` }
case 'style':
return { placement: 'head', markup: `<style>${row.text}</style>` }
case 'html':
@@ -208,6 +208,7 @@ describe('real Loader composition', () => {
table.push(
{ kind: 'script', placement: 'head', text: 'window.__Q__=1' },
{ kind: 'script-src', placement: 'head', src: '/plugins/a.js?rev="1"&x=<y>' },
{ kind: 'script-preload', src: '/plugins/b.js?rev="2"&x=<z>' },
{ kind: 'global', name: '__DSH_BOOT__', value: { rev: '</script><b>' } },
{ kind: 'style', text: 'body{margin:0}' },
{ kind: 'html', placement: 'head', html: '<meta name="probe">' },
@@ -222,6 +223,7 @@ describe('real Loader composition', () => {
'<head>',
'<script>window.__Q__=1</script>',
'<script src="/plugins/a.js?rev=&quot;1&quot;&amp;x=&lt;y&gt;"></script>',
'<link rel="preload" as="script" href="/plugins/b.js?rev=&quot;2&quot;&amp;x=&lt;z&gt;">',
'globalThis["__DSH_BOOT__"] = {"rev":"\\u003c/script>\\u003cb>"}',
'<style>body{margin:0}</style>',
'<meta name="probe">',
+40 -2
View File
@@ -1,7 +1,10 @@
/**
* Pins shared client-bundle preset rules: the module-edge purity gate and
* the physical watch dependencies hidden behind virtual CSS Modules.
* Pins shared client-bundle preset rules: module-edge purity, source-map
* chaining, and physical watch dependencies hidden behind virtual CSS Modules.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it, vi } from 'vitest'
import { clientBundle, requestedExternals } from '../packages/client/tsdown.client.ts'
@@ -14,6 +17,11 @@ interface CssModulePlugin {
load?: (this: { addWatchFile: (id: string) => void }, id: string) => Promise<unknown>
}
interface SourceMapPlugin {
name: string
load?: (id: string) => Promise<unknown>
}
/** A representative dynamic bundle using the shared client baseline. */
const REQUESTING_PACKAGE = '@deepseek-ai/dsh-client-ui-conversation'
@@ -59,6 +67,14 @@ function cssModulePlugin(): CssModulePlugin {
return plugin
}
function sourceMapPlugin(): SourceMapPlugin {
const configs = clientConfigs()
const plugins = (configs[0] as { plugins: SourceMapPlugin[] }).plugins
const plugin = plugins.find(candidate => candidate.name === 'dsh-tsc-sourcemap')
if (plugin?.load === undefined) throw new Error('tsc sourcemap plugin missing from client config')
return plugin
}
describe('client bundle purity gate', () => {
const resolveId = purityResolveId()
@@ -143,6 +159,28 @@ describe('client bundle debug artifacts', () => {
it('emits source maps for plugin TS and TSX outside the Vite module graph', () => {
const configs = clientConfigs()
expect(configs[0]?.sourcemap).toBe(true)
expect(configs[0]?.outputOptions).toMatchObject({ sourcemapExcludeSources: false })
})
it('chains emitted tsc maps when the production Client build consumes lib/types', async () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-client-sourcemap-'))
try {
const entry = join(root, 'lib', 'types', 'client', 'index.js')
const source = join(root, 'src', 'client', 'index.ts')
const map = { version: 3, names: [], mappings: 'AAAA', sources: ['../../../src/client/index.ts'] }
mkdirSync(join(root, 'lib', 'types', 'client'), { recursive: true })
mkdirSync(join(root, 'src', 'client'), { recursive: true })
writeFileSync(entry, 'export const marker = true\n//# sourceMappingURL=index.js.map\n')
writeFileSync(`${entry}.map`, JSON.stringify(map))
writeFileSync(source, 'export const marker: true = true\n')
await expect(sourceMapPlugin().load!(entry)).resolves.toEqual({
code: 'export const marker = true',
map: { ...map, sourcesContent: ['export const marker: true = true\n'] },
})
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('maps first-party sources to their repository package paths', () => {
+1
View File
@@ -573,6 +573,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
WorkspaceOrderValue: 'workspace.md',
WorkspaceRenameRequest: 'workspace.md',
WorkspaceValue: 'workspace.md',
ClientArtifactBaseline: 'client-modules.md',
WebBootGraph: 'client-modules.md',
SessionTelemetryRecord: 'session-telemetry.md',
WorkflowRunInfo: 'workflow.md',
+15
View File
@@ -1761,11 +1761,26 @@
"symbol": "WebBootEntry",
"source": "packages/client/modules/src/client/manifest.ts"
},
{
"doc": "docs/subsystems/client-modules.md",
"symbol": "WebBootBatchPhase",
"source": "packages/client/modules/src/client/manifest.ts"
},
{
"doc": "docs/subsystems/client-modules.md",
"symbol": "WebBootBatch",
"source": "packages/client/modules/src/client/manifest.ts"
},
{
"doc": "docs/subsystems/client-modules.md",
"symbol": "WebBootGraph",
"source": "packages/client/modules/src/client/manifest.ts"
},
{
"doc": "docs/subsystems/client-modules.md",
"symbol": "ClientArtifactBaseline",
"source": "packages/client/modules/src/index.ts"
},
{
"doc": "docs/subsystems/session-telemetry.md",
"symbol": "SessionTelemetrySharingStatus",