mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
perf(client-modules): batch startup plugin scripts
This commit is contained in:
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
|
||||
2026-07-23-client-plugin-loading-model.md: 02dadf6e1dc1f2c4fd99907446bc6d07b35ba471
|
||||
2026-07-23-client-plugin-loading-model.zh.md: eaf10d32a6b51189867d2a52f76dc190380cbca0
|
||||
2026-07-23-client-plugin-loading-model.md: 07c3f2a1f2cb60a6e60e33c81dcf1c9060d7aebb
|
||||
2026-07-23-client-plugin-loading-model.zh.md: 5d8c553f0d7eda3c05fdaecf1ac2ee6a515b8304
|
||||
|
||||
@@ -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/...`. Other workspace sources inlined into a bundle likewise resolve to their `packages/` owner, while dependency paths remain unchanged; `sourcesContent` carries the source. 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. 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. Each bundle plus its available map hashes into the row `rev`; batch revisions hash their 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. It reads bundle paths from `ctx.clientModules.clientPath(id)`, and one HMR-owned interval stat-polls every current graph row's script and optional map. Adding a row is ordered as synchronous artifact 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 script/map mtime or size delta, or a 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 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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
+13
-13
@@ -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 row;HTML 在 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/...`。内联进 bundle 的其他 workspace 源码同样回到其 `packages/` 归属,依赖包路径保持原样;`sourcesContent` 承载源码。批次生成会移除每个局部 `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,并为两个启动批次增加按内容寻址的描述。版本化脚本与 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,初扫与稳态共享一条实现。每个 bundle 及其可用 map 共同哈希为 row `rev`;批次 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` row;prefetch 会递归登记已声明的动态请求和 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 监视与 SSE(Server-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 半自己观察——没有构建器来通知它。它从 `ctx.clientModules.clientPath(id)` 读取图上各行的 bundle 路径,由 HMR 自持的单个定时器对当前图每一行的脚本及可选 map 做 stat 轮询。新增图行时,顺序固定为先同步取得产物基线,再立即调用 `clientModuleHost.rebuilt(id)`:在模块 host 算出图哈希之后、取得基线之前发生的写入会被这次立即重哈希捕获;取得基线之后发生的写入则会留下 stat 差异,供下一次轮询捕获。这避开了 `fs.watchFile`:它以异步首次 stat 建立基线,可能把构造期间的重建静默吸收进基线。监视集合的成员随 `onGraphChanged` 更新;消失的行撤下监视,轮询时缺失的 bundle 则让对应行保持标脏状态,文件重现时即使元数据相同也强制重哈希。脚本/map 的 mtime 或 size 变化,或行处于标脏状态时,`clientModuleHost.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 状态。
|
||||
|
||||
名册位于 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
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-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: 0bb89a23778c84598964bf408d1a66a16f062687
|
||||
2026-08-15-client-shells-and-dynamic-packages.zh.md: 4a0488b678368882a4724733ebea52cc6e9440a9
|
||||
|
||||
+5
-5
@@ -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 and runtime.
|
||||
3. Execute one blocking bootstrap batch containing the ordinary modules and runtime factory registrations.
|
||||
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 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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -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 and runtime package ids, and modules must remain self-contained at runtime. Batch generation preserves those two ordinary package artifacts 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.
|
||||
|
||||
|
||||
+5
-5
@@ -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 与 runtime 之外的全部 row。
|
||||
3. 执行一个阻塞式 bootstrap 批次,其中包含普通的 modules 与 runtime 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 批次只登记 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 的自举要求。
|
||||
|
||||
`immediately` 层级完成 factory 注册后,内核创建全部 Loader entry,等待 Cordis 静止,并要求每个 fiber 都进入 ACTIVE。随后调用 `ctx.uiRenderer.mount(container)`。动态 `ui-renderer` 包拥有 React、slot 渲染、已有启动 DOM 的 hydrate 和 React root 生命周期;启动内核与失败页保持 React-free。
|
||||
|
||||
@@ -79,7 +79,7 @@ Modules Node 半按以下顺序向实际返回的 HTML 注入启动协议:
|
||||
|
||||
Npm 依赖在 peer 与开发区段间移动时,bundle 内容保持稳定,因为每个构建 face 都直接声明 external。静态库继续由宿主装配,动态包则保留统一产物与生命周期治理。
|
||||
|
||||
启动协议依赖 modules 和 runtime 的 package id,modules 还必须保持运行期自包含。缺少 bootstrap registration 会在 Cordis 启动前失败;后续插件 import、apply 与 service 等待失败仍由启动页的 ACTIVE 扫描呈现。
|
||||
启动协议依赖 modules 和 runtime 的 package id,modules 还必须保持运行期自包含。批次生成保留这两个普通 package 产物,并为其他全部 row 提供一条共享初始传输;HMR 仍使用每个 row 带 revision 的独立产物。缺少 bootstrap registration 会在 Cordis 启动前失败;后续插件 import、apply 与 service 等待失败仍由启动页的 ACTIVE 扫描呈现。
|
||||
|
||||
外壳消费已构建 `lib/` 产品,因此在相关 build 或 watcher 运行前,源码与浏览器产物可能漂移。仅源码 typecheck 通过不能证明实际服务的应用使用同一份代码。
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -256,11 +256,39 @@ 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>()
|
||||
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 })
|
||||
expect([...new Set(pluginScripts)].sort()).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',
|
||||
])
|
||||
} finally {
|
||||
await browser?.close()
|
||||
const closed = child.exitCode === null
|
||||
? new Promise<void>((resolveClose) => { child.once('close', () => { resolveClose() }) })
|
||||
: Promise.resolve()
|
||||
|
||||
@@ -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: b935c7328ff2fc5e8ac5ab427a95fcae01e8df22
|
||||
client-modules.zh.md: f65b764a1d66239c40117cabf2b286a3894ce07d
|
||||
|
||||
@@ -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). */
|
||||
/** Hash over the individual bundle and available source map. */
|
||||
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 row's `rev` hashes the individual bundle and its 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,7 +82,7 @@ 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
|
||||
|
||||
|
||||
@@ -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). */
|
||||
/** Hash over the individual bundle and available source map. */
|
||||
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 时才拉取。
|
||||
每一行的 `rev` 都对独立 bundle 及其可用 sourcemap 求哈希。Bootstrap 批次包含 modules row;预加载的 application 批次包含其他全部 row。批次 revision 对生成的脚本与 indexed sourcemap 求哈希,图 revision 则对 row 与批次描述一并求哈希。`immediately` 标记第一阶段的 registration barrier;即使只有部分 application row 携带该标记,它们仍共享一次脚本传输。
|
||||
|
||||
## 扫描
|
||||
|
||||
@@ -61,7 +82,7 @@ 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 渲染时携带当前图,因此重新加载总是基于实时组合启动。
|
||||
|
||||
## 服务
|
||||
|
||||
|
||||
@@ -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: 5e1d960515094d1c4f18b69ec01d49dd780d0cb5
|
||||
README.zh.md: 2c82baac20f2a68b91c25e80ee4714b6ceb2e80c
|
||||
|
||||
@@ -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 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 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.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
为通过脚本加载的客户端插件提供热重载。web 组合包无条件挂载该行;没有重建 watcher(`pnpm run dev:web`)改写客户端 bundle 时,轮询观察不到变化,链路保持空闲。
|
||||
|
||||
浏览器侧订阅系统 SSE(Server-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 通道。
|
||||
浏览器侧订阅系统 SSE(Server-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 检测重建:从同步基线开始 stat-poll 每个图 bundle 及其可选 sourcemap;新增一行后立即重新计算 hash;缺失行保持 dirty;只广播真实 rev 变更。因此,任何生成这些产物的 tsdown watch 进程都能触发 HMR(热模块替换),无需 builder→host 通道。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -18,4 +18,4 @@
|
||||
|
||||
- **重载有意保持粗粒度**:会创建全新的 fiber 和组件;重载插件中的 React 状态会丢失,数据层(连接 fiber、运行时 fiber 和 Session 对象)不受影响。react-refresh 级状态保留与「重新执行组合包会重新运行 factory」冲突,因此有意排除。
|
||||
- **失败时不回滚**:失败的重载会使配置项处于 FAILED 状态,并在 loader 状态投影中显示;系统不会自动恢复先前组合包。
|
||||
- **重建帧不会刷新图 rev**:陈旧 rev 无害,因为组合包端点以 no-cache 提供内容;只有重新连接时才会刷新。
|
||||
- **重建帧不会替换启动图**:每个帧都会携带本次重载所需的独立脚本 revision;页面重载时才接收重新组合的批次图。
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
@@ -42,13 +42,38 @@ function sseData(frame: PluginsEventFrame): string {
|
||||
return `data: ${JSON.stringify(frame)}\n\n`
|
||||
}
|
||||
|
||||
interface WatchedBundle {
|
||||
path: string
|
||||
interface WatchedArtifactStat {
|
||||
mtimeMs: number
|
||||
size: number
|
||||
mapMtimeMs: number | null
|
||||
mapSize: number | null
|
||||
}
|
||||
|
||||
interface WatchedBundle extends WatchedArtifactStat {
|
||||
path: string
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the dev chain: bundle watches, rebuilt reporting, and the SSE channel.
|
||||
* @param ctx - host plugin context carrying clientModuleHost and webServer.
|
||||
@@ -61,7 +86,7 @@ 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).
|
||||
@@ -76,19 +101,21 @@ 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 }
|
||||
let baseline: WatchedArtifactStat
|
||||
try {
|
||||
baseline = statSync(path)
|
||||
baseline = artifactStat(path)
|
||||
} catch (error) {
|
||||
watched.set(id, { path, mtimeMs: 0, size: 0, dirty: true })
|
||||
watched.set(id, { path, mtimeMs: 0, size: 0, mapMtimeMs: null, mapSize: null, 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 }
|
||||
const watch: WatchedBundle = { path, ...baseline, 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
|
||||
@@ -98,15 +125,15 @@ export function apply(ctx: Context, config: Config): void {
|
||||
|
||||
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)
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -40,6 +40,7 @@ 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),
|
||||
@@ -100,6 +101,11 @@ describe('hmr node half', () => {
|
||||
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.
|
||||
|
||||
@@ -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: bd6d53de9d847b02fc7a8b1030efe4f9036e44ab
|
||||
README.zh.md: d78a2ddc836c1266e92c5ca95b4e54b78bdcbf98
|
||||
|
||||
@@ -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. 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
|
||||
|
||||
|
||||
@@ -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 文件。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)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -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). */
|
||||
/** Hash over the individual bundle and available source map. */
|
||||
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
|
||||
/** Hash over the individual bundle and available source map. */
|
||||
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. */
|
||||
|
||||
@@ -26,6 +26,14 @@ const defaultLoadBundle = (url: string): Promise<void> => new Promise((resolve,
|
||||
document.head.append(el)
|
||||
})
|
||||
|
||||
/** Replace the rev query while preserving same-origin relative URLs. */
|
||||
function atRevision(url: string, rev: string): string {
|
||||
const absolute = /^[A-Za-z][A-Za-z\d+.-]*:/.test(url)
|
||||
const parsed = new URL(url, 'http://dsh.invalid')
|
||||
parsed.searchParams.set('rev', rev)
|
||||
return absolute ? parsed.href : `${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 +66,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 +121,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 +237,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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -23,7 +24,6 @@
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
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
|
||||
@@ -120,8 +120,27 @@ class ClientPackageCompositionError extends AggregateError {
|
||||
interface WebPluginRecord {
|
||||
entry: WebBootEntry
|
||||
meta: PkgMeta
|
||||
/** Exact build artifact whose hash produced entry.rev. */
|
||||
bundle: Buffer
|
||||
/** 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
|
||||
@@ -163,6 +182,113 @@ 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 under one individual artifact revision. */
|
||||
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 +348,22 @@ 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
|
||||
|
||||
/** Escape a graph URL before placing it in a quoted HTML attribute. */
|
||||
function escapeHtmlAttribute(value: string): string {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +394,21 @@ 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: 'html',
|
||||
placement: 'head',
|
||||
html: `<link rel="preload" as="script" href="${escapeHtmlAttribute(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 +430,9 @@ export class ClientModuleRegistry extends Service {
|
||||
private readonly graphListeners = new Set<() => void>()
|
||||
private readonly dirty = new Set<string>()
|
||||
private readonly resolvePkgJson: (spec: string) => string
|
||||
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
|
||||
|
||||
@@ -368,9 +513,22 @@ 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 bundle = readFileSync(record.meta.clientPath)
|
||||
let sourceMap: WebPluginRecord['sourceMap']
|
||||
try {
|
||||
sourceMap = sourceMapSnapshot(record.meta.clientPath)
|
||||
} catch (error) {
|
||||
// A client rebuild remains reloadable when its development-only map is
|
||||
// temporarily incomplete; this revision simply exposes no map.
|
||||
this.ctx.logger.warn(error)
|
||||
}
|
||||
const rev = artifactRevision(bundle, sourceMap)
|
||||
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 +566,36 @@ 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 records = new Map([...this.table.entries()])
|
||||
const bootstrap = PARSER_PRELOAD_IDS
|
||||
.map(id => records.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 => records.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 {
|
||||
@@ -460,15 +647,21 @@ export class ClientModuleRegistry extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 bundle content revision.
|
||||
* @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
|
||||
rev: string
|
||||
sourceMap?: WebPluginRecord['sourceMap']
|
||||
} {
|
||||
try {
|
||||
return shortHash(readFileSync(clientPath))
|
||||
const bundle = readFileSync(clientPath)
|
||||
const sourceMap = sourceMapSnapshot(clientPath)
|
||||
return { bundle, rev: artifactRevision(bundle, sourceMap), ...(sourceMap === undefined ? {} : { sourceMap }) }
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
throw new MissingClientBundleError(pkgName, clientPath, error)
|
||||
@@ -490,8 +683,14 @@ export class ClientModuleRegistry extends Service {
|
||||
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 })
|
||||
const snapshot = this.initialBundleSnapshot(entryName, meta.clientPath)
|
||||
this.table.set(entryName, {
|
||||
entry: graphRow(entryName, snapshot.rev, meta),
|
||||
meta,
|
||||
bundle: snapshot.bundle,
|
||||
individualBundle: individualBundle(snapshot.bundle, snapshot.rev, snapshot.sourceMap !== undefined),
|
||||
...(snapshot.sourceMap === undefined ? {} : { sourceMap: snapshot.sourceMap }),
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -523,14 +722,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 +747,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,32 @@ 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('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 +499,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 +520,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([])
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, 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,161 @@ describe('client bundle activation', () => {
|
||||
expect(String(thrown)).not.toContain('pnpm run build')
|
||||
})
|
||||
|
||||
it('rejects a malformed built source map during composition', () => {
|
||||
const packageName = '@fixture/malformed-source-map'
|
||||
const clientPath = writePackage(packageName)
|
||||
mkdirSync(dirname(clientPath), { recursive: true })
|
||||
writeFileSync(clientPath, 'module.exports = {}\n')
|
||||
writeFileSync(`${clientPath}.map`, '{}\n')
|
||||
expect(() => construct([packageName]))
|
||||
.toThrow(`${clientPath}.map is not a regular Source Map v3 object`)
|
||||
|
||||
writeFileSync(`${clientPath}.map`, '{"version":3,"sources":[null]}\n')
|
||||
expect(() => construct([packageName]))
|
||||
.toThrow(`${clientPath}.map is not a regular Source Map v3 object`)
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
writeFileSync(clientPath, 'module.exports = { generation: 2 }\n')
|
||||
service.rebuilt(packageName)
|
||||
const second = service.graph().batches[0]!.url
|
||||
expect(second).not.toBe(first)
|
||||
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('frames bundle and map fields before hashing an immutable revision', () => {
|
||||
const packageName = '@fixture/framed-artifact-hash'
|
||||
const clientPath = writePackage(packageName)
|
||||
mkdirSync(dirname(clientPath), { recursive: true })
|
||||
const map = '{"version":3,"names":[],"mappings":"AAAA","sources":["src.ts"]}\n'
|
||||
writeFileSync(clientPath, 'module.exports = {} ')
|
||||
writeFileSync(`${clientPath}.map`, map)
|
||||
const first = construct([packageName]).graph().entries[0]!.rev
|
||||
|
||||
writeFileSync(clientPath, 'module.exports = {}')
|
||||
writeFileSync(`${clientPath}.map`, ` ${map}`)
|
||||
const second = construct([packageName]).graph().entries[0]!.rev
|
||||
expect(second).not.toBe(first)
|
||||
})
|
||||
|
||||
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')
|
||||
expect(construct([packageName]).graph().entries[0]?.rev).not.toBe(row.rev)
|
||||
})
|
||||
|
||||
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' })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 创建顺序不依赖该激活时点。
|
||||
|
||||
|
||||
@@ -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"')
|
||||
})
|
||||
})
|
||||
@@ -159,7 +163,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 +201,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)
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -5443,13 +5443,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',
|
||||
|
||||
@@ -1761,6 +1761,16 @@
|
||||
"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",
|
||||
|
||||
Reference in New Issue
Block a user