mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
Merge pull request #2674 from deepseek-harness/feat/http-gzip
为 Web 静态资源与 API 响应增加 gzip 协商压缩
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: dfa9f34276f20ffa99541db1544539d693313a2f
|
||||
2026-07-23-client-plugin-loading-model.zh.md: 68fe9b912c60aceb2ecea315ed0121f9f96c1ecf
|
||||
2026-07-23-client-plugin-loading-model.md: bd6f6e58c571102afc789ef57085db1e302158cc
|
||||
2026-07-23-client-plugin-loading-model.zh.md: 256b57102bbec6f793d48d0bdaf60445b194ecdf
|
||||
|
||||
@@ -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 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.
|
||||
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 belongs to an application combo script; 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`.
|
||||
|
||||
### Batched external-script arrival and source maps
|
||||
### Combo external-script arrival and source maps
|
||||
|
||||
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 Host snapshots every built plugin artifact and partitions each scheduling phase's ordered rows into one or more same-origin classic scripts. It greedily fills each group while the longer map-form request URL remains within 3 KiB, preserving graph order and allowing another request instead of emitting an oversized URL. Each script is addressed by its package resources, for example `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>`. The `bootstrap` and `application` values are scheduling phases in the graph, not URL components: HTML preloads every application URL before executing every parser-blocking bootstrap URL. The module system keys in-flight transport by combo URL, so concurrent row arrivals within one group execute one script. Successful settlement still requires each requested row's factory id to exist in the module table, and registration does not run the factory, so the side-effect boundary remains first materialization.
|
||||
|
||||
The shared tsdown preset emits `client.js.map` for every plugin and rewrites first-party source paths into the browser-resolvable repository shape `/packages/<group>/<package>/src/...`. The production Client pass consumes `lib/types`; the preset supplies each tsc map to Rolldown and fills `sourcesContent` from the original files, so the final map reaches TypeScript/TSX instead of stopping at emitted JavaScript. Other workspace sources inlined into a bundle likewise resolve to their `packages/` owner, while dependency paths remain unchanged. Batch generation strips each local `sourceMappingURL`, records its generated-line offset, resolves every source against the original per-plugin map URL, and emits one indexed Source Map v3 file whose sections embed the available plugin maps. The Vite shell also emits source maps, letting shell code and batched or individually reloaded plugins map stacks and performance profiles back to TypeScript/TSX.
|
||||
The shared tsdown preset emits `client.js.map` for every plugin and rewrites first-party source paths into the browser-resolvable repository form `/packages/<group>/<package>/src/...`. The production Client pass consumes `lib/types`; the preset supplies each tsc map to Rolldown and fills `sourcesContent` from the original files, so the final map reaches TypeScript/TSX instead of stopping at emitted JavaScript. Other workspace sources inlined into a bundle likewise resolve to their `packages/` owner, while dependency paths remain unchanged. Combo generation strips each local debug directive, records its generated-line offset, resolves every authored source against the original per-plugin map URL, and emits an Indexed Source Map v3. An authored map supplies its section; otherwise an identity section embeds the generated bundle and uses the packer's `sourceURL` as its source name when present. The absolute map URL mirrors the script resource list by changing every `client.js` suffix to `client.js.map`, so `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` points to `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`. One resource follows the same rule and still produces an indexed map with one section. The Vite shell also emits source maps, letting shell code and combo-loaded plugins map stacks and performance profiles back to TypeScript/TSX.
|
||||
|
||||
The graph retains each row's revisioned individual URL for HMR and adds content-addressed descriptors for the two startup batches. Initial row revisions are opaque process nonces rather than content hashes; they keep an exceptional initial individual request immutable without hashing every plugin at startup. After the watcher observes one artifact change, `rebuilt(id)` hashes only that bundle and map and publishes the resulting revision. Versioned scripts and maps use immutable caching. The Host serves snapshotted bytes only when the requested revision matches; stale or missing revisions return 404 instead of aliasing newer bytes. An external script's `error` event exposes neither response status nor body, so failure diagnostics name only the URL; the same-origin Host and build-stamped registration id form the identity boundary, while the post-`load` factory-presence check rejects an artifact that did not register the expected id.
|
||||
The graph retains each row's revisioned one-resource combo URL for HMR and adds a content-addressed descriptor for every startup combo request; several descriptors may carry the same scheduling phase. Initial row revisions are opaque process nonces rather than content hashes; they keep the snapshotted one-resource response immutable without hashing every plugin at startup. After the watcher observes one artifact change, `rebuilt(id)` hashes only that bundle and map and publishes the resulting revision. Startup combo revisions cover the combined script inputs and indexed map. Versioned scripts and maps use immutable caching. The Host serves only exact generated URLs; stale revisions and unadvertised resource lists return 404 instead of aliasing different bytes. An external script's `error` event exposes neither response status nor body, so failure diagnostics name only the URL; the same-origin Host and build-stamped registration id form the identity boundary, while the post-`load` factory-presence check rejects an artifact that did not register the expected id.
|
||||
|
||||
### The loading flow, end to end
|
||||
|
||||
@@ -54,11 +54,11 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the
|
||||
|
||||
1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, including the always-mounted `client-hmr` row. A roster row that fails to import is caught by `assertEntriesLoaded`; a row whose fiber rejects is reported with its original stack by `assertEntriesActivated` ([host boot decision](2026-07-24-web-config-tree-boot-and-transport-layering.md)).
|
||||
2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dsh.client` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`. The row's three optional fields come from manifests, never hand-copied. Composition orders requested dynamic rows before their consumers, rejects synchronous request cycles, and assigns every row to exactly one initial batch. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the Host audit reports either error from the FAILED fiber.
|
||||
3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per name forever and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Initial rows receive an opaque process nonce plus sequence without hashing their artifacts; batch revisions hash the generated script plus indexed map, and the rows plus batch descriptors hash into `graph.rev`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph, while modules registers the bundle route and contributes structured index-injection rows.
|
||||
3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per name forever and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Initial rows receive an opaque process nonce plus sequence without hashing their artifacts; startup combo revisions hash the combined script inputs plus indexed map, and the rows plus batch descriptors hash into `graph.rev`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph, while modules registers the combo route and contributes structured index-injection rows.
|
||||
|
||||
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, 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 one — the module face.** The injected HTML installs `window.__ModuleLoader__` in queue mode, starts preloading every application combo URL, executes every bootstrap combo URL as a 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. Rows in the same application combo share its execution; separate combos load independently when an immediate row, a requested dependency, or ordinary entry import reaches them. 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.**
|
||||
|
||||
@@ -76,8 +76,8 @@ How does a rebuilt bundle become a reload signal? The hmr node half observes it
|
||||
|
||||
On the browser side, the driver reloads one plugin per frame, serialized:
|
||||
|
||||
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.
|
||||
1. `invalidate` — drop the stale factory and record, and bind the rebuilt frame's revision to that row's one-resource combo URL. A live factory would make the next step a no-op.
|
||||
2. `prefetch` — load that one-resource external script and register the fresh factory while the old fiber still serves. The initial multi-resource script 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.
|
||||
@@ -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 guide factory arrival but service availability remains the activation authority, so a mismatch appears at the settled sweep; the static UI libraries keep direct value exports; every bundle gains a source-map artifact; and external-script failures provide only coarse URL diagnostics instead of the HTTP status available to an explicit fetch. The Host retains per-plugin bundle/map snapshots, revision-stamped individual responses, current batches, and one previous batch generation, so memory scales as several copies of the composed client artifacts. This retained state keeps URLs immutable and lets an in-flight request finish across one HMR recomposition.
|
||||
Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows guide factory arrival but service availability remains the activation authority, so a mismatch appears at the settled sweep; the static UI libraries keep direct value exports; every bundle gains a source-map artifact; and external-script failures provide only coarse URL diagnostics instead of the HTTP status available to an explicit fetch. The Host retains per-plugin bundle/map snapshots, generated one-resource responses, current startup combo responses, and one previous startup generation, so memory scales as several copies of the composed client artifacts. This retained state keeps URLs immutable and lets an in-flight request finish across one HMR recomposition.
|
||||
|
||||
Roster: it lives in the web bundle's config tree (`packages/bundle/web-app/cordis.patch.yml`); `mountWebPlugins` and the `CLIENT_PACKAGES` constant are gone, and recomposing a deployment means swapping the yml/overlay. The graph composer lives in the `dsh-client-modules` node half, while the parser-preloaded client face bootstraps the browser module table. The webserver remains a plain route-registration plugin; `/api/*` binding belongs to the connection node half over `api-gateway` (`dsh-host-apiproxy` providing `ctx.apiProxy`), and the dev bundle watch plus SSE channel belongs to the hmr node half.
|
||||
|
||||
|
||||
+10
-10
@@ -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 构造模块系统。其他动态图 row 全部经 application 批次到达;React、Cordis 与静态 UI 库的身份由外壳 seed 提供。
|
||||
Web 内核保持不依赖框架,也不 import 任何动态包实体。Modules 本身是动态图 row,但 host parser 会在 Vite 主模块前送达其 factory。内核调用 `create()` 时,由 HTML 安装的 `__ModuleLoader__` facade 使用该 factory 构造模块系统。其他每个动态图 row 都归属一个 application combo 脚本;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`。
|
||||
|
||||
### 批量外部脚本到达与源码映射
|
||||
### Combo 外部脚本到达与源码映射
|
||||
|
||||
Host 会快照每个已构建插件产物,并把其 factory registration 拼入两个同源 classic script 之一。阻塞 parser 的 `bootstrap` 批次包含 modules row;HTML 在 bootstrap 执行期间预加载包含其余全部 graph row 的 `application` 批次。模块系统按批次 URL 复用进行中的传输,因此并发 row 到达只执行一次 application 脚本。成功结算仍要求模块表中已经存在被请求 row 的 factory id;登记不会运行 factory,所以副作用边界依然是首次物化。
|
||||
Host 会快照每个已构建插件产物,并把每个调度阶段的有序 row 划入一个或多个同源 classic script。它在更长的 map 形式请求 URL 保持在 3 KiB 以内时贪心填充每组,既保留 graph 顺序,也以增加请求代替超长 URL。每个脚本都由其中的 package 资源寻址,例如 `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>`。`bootstrap` 与 `application` 是图中的调度阶段,不是 URL 组成部分:HTML 先预加载所有 application URL,再执行所有阻塞 parser 的 bootstrap URL。模块系统按 combo URL 复用进行中的传输,因此同组 row 的并发到达只执行一个脚本。成功结算仍要求模块表中已经存在被请求 row 的 factory id;登记不会运行 factory,所以副作用边界依然是首次物化。
|
||||
|
||||
共享 tsdown 预设为每个插件产出 `client.js.map`,并把第一方源码路径重写成浏览器可识别的仓库形状 `/packages/<group>/<package>/src/...`。生产 Client 构建会消费 `lib/types`;预设把每份 tsc map 交给 Rolldown,并从原文件补齐 `sourcesContent`,使最终 map 回到 TypeScript/TSX,而不是停在编译后的 JavaScript。内联进 bundle 的其他 workspace 源码同样回到其 `packages/` 归属,依赖包路径保持原样。批次生成会移除每个局部 `sourceMappingURL`、记录其生成行偏移、以原插件 map URL 解析每个 source,再产出一份以 section 内嵌现有插件 map 的 indexed Source Map v3 文件。Vite 壳也产出 sourcemap,使壳代码以及批量或独立重载的插件都能从 stack 和性能 profile 回到 TypeScript/TSX。
|
||||
共享 tsdown 预设为每个插件产出 `client.js.map`,并把第一方源码路径重写成浏览器可识别的仓库形式 `/packages/<group>/<package>/src/...`。生产 Client 构建会消费 `lib/types`;预设把每份 tsc map 交给 Rolldown,并从原文件补齐 `sourcesContent`,使最终 map 回到 TypeScript/TSX,而不是停在编译后的 JavaScript。内联进 bundle 的其他 workspace 源码同样回到其 `packages/` 归属,依赖包路径保持原样。Combo 生成会移除每个局部调试指令、记录其生成行偏移、以原插件 map URL 解析每个自带 source,再产出 Indexed Source Map v3。插件有自带 map 时直接用于对应 section;没有时则生成 identity section,内嵌构建后 bundle,并在存在时把 packer 写入的 `sourceURL` 用作 source 名。绝对 map URL 会平行改写脚本资源列表中的每个 `client.js` 后缀,因此 `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` 指向 `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`。单资源也采用相同规则,仍产出只有一个 section 的 indexed map。Vite 壳同样产出 sourcemap,使壳代码与经 combo 加载的插件都能从 stack 和性能 profile 回到 TypeScript/TSX。
|
||||
|
||||
图为 HMR 保留每个 row 带 revision 的独立 URL,并为两个启动批次增加按内容寻址的描述。初始 row revision 是进程级不透明 nonce,而不是内容哈希;它无需在启动时哈希每个插件,也能保证异常情况下的初始独立请求不可变。watcher 观察到某个产物变化后,`rebuilt(id)` 只哈希该 bundle 与 map,并发布所得 revision。版本化脚本与 map 使用 immutable 缓存。Host 只在请求 revision 匹配时提供已快照字节;陈旧或缺失 revision 返回 404,不会在旧 URL 下别名到新字节。外部脚本的 `error` 事件不给响应状态与正文,因此失败诊断只报告 URL;同源 Host 与构建期写入的 registration id 是身份边界,`load` 后的 factory 存在性检查负责拒绝未登记预期 id 的产物。
|
||||
图为 HMR 保留每个 row 带 revision 的单资源 combo URL,并为每个启动 combo 请求增加按内容寻址的描述;多条描述可以使用同一调度阶段。初始 row revision 是进程级不透明 nonce,而不是内容哈希;它无需在启动时哈希每个插件,也能保证已快照的单资源响应不可变。watcher 观察到某个产物变化后,`rebuilt(id)` 只哈希该 bundle 与 map,并发布所得 revision。启动 combo revision 覆盖合并脚本输入与 indexed map。版本化脚本与 map 使用 immutable 缓存。Host 只提供精确生成的 URL;陈旧 revision 与未发布资源列表返回 404,不会别名到其他字节。外部脚本的 `error` 事件不给响应状态与正文,因此失败诊断只报告 URL;同源 Host 与构建期写入的 registration id 是身份边界,`load` 后的 factory 存在性检查负责拒绝未登记预期 id 的产物。
|
||||
|
||||
### 装载流程,端到端
|
||||
|
||||
@@ -54,11 +54,11 @@ Host 会快照每个已构建插件产物,并把其 factory registration 拼
|
||||
|
||||
1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,包括无条件挂载的 `client-hmr` 行。名册行 import 失败由 `assertEntriesLoaded` 捕获;fiber reject 的行则由 `assertEntriesActivated` 报告原始 stack([host boot 决策](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md))。
|
||||
2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dsh.client` 声明,组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`。Row 的三个可选字段都来自 manifest,永不人肉抄写。组合会把被请求的动态图 row 排到消费者之前、拒绝同步请求环,并把每个 row 恰好分配给一个初始批次。它会拒绝没有已构建 `./client` bundle 的已声明插件,并把它们的 package/path 行归到一条源码构建要求下;畸形声明字段同样会让激活失败,Host 检查会从 FAILED fiber 报告这两类错误。
|
||||
3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按名永久缓存,bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。初始 row 使用不透明的进程 nonce 加序号,不对其产物求哈希;批次 revision 对生成的脚本及 indexed map 求哈希,row 与批次描述再共同哈希进 `graph.rev`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知;modules 会注册 bundle 路由并贡献结构化 index 注入行。
|
||||
3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按名永久缓存,bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。初始 row 使用不透明的进程 nonce 加序号,不对其产物求哈希;启动 combo revision 对合并脚本输入及 indexed map 求哈希,row 与批次描述再共同哈希进 `graph.rev`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知;modules 会注册 combo 路由并贡献结构化 index 注入行。
|
||||
|
||||
为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个在仓库中声明了 dsh.client 的包,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。
|
||||
|
||||
**第一阶段——模块面。**注入的 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,不是包身份。
|
||||
**第一阶段——模块面。**注入的 HTML 以 queue 模式安装 `window.__ModuleLoader__`,开始预加载所有 application combo URL,以阻塞式 classic script 依次执行所有 bootstrap combo URL,赋值 `window.__DSH_BOOT__`,然后启动 Vite 主模块。内核把原始图和外壳 seed 传给 facade 的 `create()`。Facade 移除 modules registration,用拒绝全部 external 的 bootstrap `require` 将其物化,再调用其 `createClientModuleSystem` 导出。Modules bundle 解析图、构造系统、记忆化自身 exports、在模块闭包中保留该实例,并把同一 facade 切换到 live registration。随后内核并行预取每个 `immediately` row;同一 application combo 中的 row 共享一次执行,不同 combo 会在 immediate row、被请求依赖或普通 entry import 首次触及时独立加载。预取失败在这里被吞下,因为第二阶段 import 会重试并拥有那次大声失败。`immediately` 仍是 registration barrier,不是包身份。
|
||||
|
||||
**第二阶段——插件面。**
|
||||
|
||||
@@ -76,8 +76,8 @@ Host 会快照每个已构建插件产物,并把其 factory registration 拼
|
||||
|
||||
浏览器侧,驱动插件每帧重载一个插件,串行执行:
|
||||
|
||||
1. `invalidate`——丢弃陈旧的 factory 与记录,并把 rebuilt 帧的 revision 绑定到该 row 的独立 URL。Factory 还活着会让下一步变成 no-op。
|
||||
2. `prefetch`——加载独立外部脚本并登记新 factory,旧 fiber 此刻仍在服役。初始批次不会再次执行。
|
||||
1. `invalidate`——丢弃陈旧的 factory 与记录,并把 rebuilt 帧的 revision 绑定到该 row 的单资源 combo 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>` 标签。
|
||||
@@ -96,7 +96,7 @@ Host 会快照每个已构建插件产物,并把其 factory registration 拼
|
||||
|
||||
Wire 两侧运行同一份治理实现;浏览器特有层只包含一套模块系统和一个重载插件。动态包只有一种产物形态,因此纯度检查覆盖全部动态包。Cordis 依赖、模块请求与启动档位都与其所有者——manifest——同住,负责组合的 app 只握名册。Host graph 校验与递归请求到达使同步 factory 依赖保持显式。浏览器原生 script 装载保留插件网络资源、生成 bundle 与 TypeScript/TSX 源码之间的标准映射,模块系统也只保留一个可替换的 `loadBundle` 钩子。
|
||||
|
||||
接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;graph `inject` row 指导 factory 到达,但服务可用性仍是激活权威,因此不匹配会在 settled 扫描时浮出;静态 UI 库保留直接实体导出;每个 bundle 多出一份 sourcemap 产物,外部 script 失败也只能给出粗粒度 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。Host 会保留逐插件 bundle/map 快照、带 revision 的独立响应、当前批次及上一代批次,因此内存会随组合出的客户端产物增长为数份副本。这组保留状态使 URL 保持不可变,并让进行中的请求跨越一次 HMR 重组后仍能完成。
|
||||
接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;graph `inject` row 指导 factory 到达,但服务可用性仍是激活权威,因此不匹配会在 settled 扫描时浮出;静态 UI 库保留直接实体导出;每个 bundle 多出一份 sourcemap 产物,外部 script 失败也只能给出粗粒度 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。Host 会保留逐插件 bundle/map 快照、生成的单资源响应、当前启动 combo 响应及上一代启动响应,因此内存会随组合出的客户端产物增长为数份副本。这组保留状态使 URL 保持不可变,并让进行中的请求跨越一次 HMR 重组后仍能完成。
|
||||
|
||||
名册位于 web 组合包的配置树(`packages/bundle/web-app/cordis.patch.yml`);`mountWebPlugins` 与 `CLIENT_PACKAGES` 常量已消失,重组一次部署等于替换 yml/overlay。Graph 组合器位于 `dsh-client-modules` node 半,由 parser 预载的 client face 则自举浏览器模块表。Webserver 继续作为朴素路由注册插件;`/api/*` 绑定属于 connection node 半,并经 `api-gateway`(由 `dsh-host-apiproxy` 提供 `ctx.apiProxy`);开发期 bundle 监视与 SSE 通道属于 hmr node 半。
|
||||
|
||||
|
||||
+2
-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-24-web-config-tree-boot-and-transport-layering.md
|
||||
2026-07-24-web-config-tree-boot-and-transport-layering.md: eb30ba84ef293a169931ef6519a9d6d2ea98af7f
|
||||
2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: a3e310a4a5ab8bc6a40ad8d0336cb94e29c1744f
|
||||
2026-07-24-web-config-tree-boot-and-transport-layering.md: 3d1ccc2a0f71411d496466288934d9038425e7cf
|
||||
2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: c2409e128dfdbd8550bb7052a7e0f67a40fe1d1f
|
||||
|
||||
+3
-1
@@ -18,7 +18,7 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)
|
||||
|
||||
**Config sources have one declaration place each.** Bundle yml values are engineering defaults, Settings sections are writable user preferences, CLI flags address their owning launcher rows, and env values enter through yml `!!js` expressions. Patches replace a row's config wholesale. The resolved frontend `distIndex` uses that patch channel as an assembly fact. The transport-independent provider/model default belongs to `ctx.agentDefaultModel`; the [direct headless entry point](2026-08-09-headless-direct-core-entry-point.md) and the Web gateway consume the same state.
|
||||
|
||||
**The transport splits five ways.** `dsh-host-apiproxy` is the gateway plugin (`api-gateway` row): it default-exports `ApiProxyService`, configures only `{nativeOpen?}`, consumes the base layer's entry-point-neutral `ctx.agentDefaultModel`, provides `ctx.apiProxy`, remains transport-agnostic, and registers no routes. `dsh-host-webserver` is a plain route-registration plugin: `WebServer` provides `ctx.webServer` (`register(route) → disposer` with duplicate-pattern throw, `renderIndex` rendering — structured `webserver/index-inject` rows, then raw `tapIndex` transforms in registration order — and `port`), listens on activation, answers per-request failures with 400 and logging, and knows no harness concepts. The connection node half owns the `/api` binding from `ctx.apiProxy` through `toFetchHandler`. The modules node half (`ClientModuleRegistry`, providing `ctx.clientModules`) owns incremental package scanning, the bundle route, the boot injection rows, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload through `fs.watchFile` membership and the `/plugins/events` SSE route.
|
||||
**The transport splits five ways.** `dsh-host-apiproxy` is the gateway plugin (`api-gateway` row): it default-exports `ApiProxyService`, configures only `{nativeOpen?}`, consumes the base layer's entry-point-neutral `ctx.agentDefaultModel`, provides `ctx.apiProxy`, remains transport-agnostic, and registers no routes. `dsh-host-webserver` is a plain route-registration plugin: `WebServer` provides `ctx.webServer` (`register(route) → disposer` with duplicate-pattern throw, `renderIndex` rendering — structured `webserver/index-inject` rows, then raw `tapIndex` transforms in registration order — and `port`), listens on activation, answers per-request failures with 400 and logging, and knows no harness concepts. Its socket-backed Node HTTP entry may apply configured gzip through maintained middleware without adding a response-writing service method or changing route owners; the Web Worker tunnel carries identity bytes. The connection node half owns the `/api` binding from `ctx.apiProxy` through `toFetchHandler`. The modules node half (`ClientModuleRegistry`, providing `ctx.clientModules`) owns incremental package scanning, the bundle route, the boot injection rows, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload through `fs.watchFile` membership and the `/plugins/events` SSE route.
|
||||
|
||||
**Package export discipline.** The modules package exposes exactly `.` (node half) and `./client` (the complete browser half: `ClientModuleSystem`, `parseBootManifest`, the adoption plugin face) — no bespoke subpaths; wire types re-export through the root for host-side consumers. The adoption handshake: the kernel writes the constructed instance to `window.__DSH_MODULES__` before cordis exists; the `./client` apply reads the slot (missing = loud throw) and provides `ctx.modules`.
|
||||
|
||||
@@ -40,3 +40,5 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)
|
||||
| env vars in the mapping table | The same field would gain env/json double sourcing and need an invented precedence |
|
||||
| Unbarriered create-after-prefetch (`arrive()` dedup as safety) | Disproved by a 10–25% boot race: in-flight dedup covers same-package double-fetch, not cross-package synchronous require edges |
|
||||
| json file used directly as loader patches | json keys would couple to yml row structure; profile writers would need cordis knowledge |
|
||||
| Public response writer plus per-route opt-in | Response coding is Node HTTP policy; exposing it through `ctx.webServer` would make every route owner and test double depend on that policy |
|
||||
| Hand-written gzip negotiation and stream lifecycle | Maintained middleware already owns negotiation, media-type filtering, header rewriting, backpressure, and threshold behavior |
|
||||
|
||||
+3
-1
@@ -18,7 +18,7 @@ Status: implemented
|
||||
|
||||
**每个配置源有唯一声明位置。** 组合包 yml 值是工程默认,Settings 分节是可写的用户偏好,CLI(命令行界面)flags 面向其归属的启动器配置行,env 值则通过 yml `!!js` 表达式进入。patch 会整体替换一行的 config。解析后的前端 `distIndex` 通过同一条 patch 通道作为组装事实传递。与传输无关的提供方/模型默认值归 `ctx.agentDefaultModel` 所有;[直接 headless 入口](2026-08-09-headless-direct-core-entry-point.zh.md)与 Web 网关消费同一份状态。
|
||||
|
||||
**传输五分。** `dsh-host-apiproxy` 是网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,只配置 `{nativeOpen?}`,消费 base 层不偏向特定入口的 `ctx.agentDefaultModel`,provide `ctx.apiProxy`,保持传输无关且不注册路由。`dsh-host-webserver` 是朴素的路由注册插件:`WebServer` provide `ctx.webServer`(`register(route) → disposer`、重复 pattern 即抛、`renderIndex` 渲染——先结构化 `webserver/index-inject` 行、后原始 `tapIndex` 按注册序应用——与 `port`),激活即 listen,单请求失败时答 400 并记日志,且不认识任何 harness 概念。connection node 半拥有从 `ctx.apiProxy` 经 `toFetchHandler` 绑定到 `/api` 的逻辑。modules node 半(`ClientModuleRegistry`,provide `ctx.clientModules`)拥有单包增量扫描、bundle 路由、启动注入行与 `onRebuilt`/`onGraphChanged` 通知。HMR(热模块替换) node 半通过 `fs.watchFile` membership 与 `/plugins/events` SSE 路由拥有开发期重载。
|
||||
**传输五分。** `dsh-host-apiproxy` 是网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,只配置 `{nativeOpen?}`,消费 base 层不偏向特定入口的 `ctx.agentDefaultModel`,provide `ctx.apiProxy`,保持传输无关且不注册路由。`dsh-host-webserver` 是朴素的路由注册插件:`WebServer` provide `ctx.webServer`(`register(route) → disposer`、重复 pattern 即抛、`renderIndex` 渲染——先结构化 `webserver/index-inject` 行、后原始 `tapIndex` 按注册序应用——与 `port`),激活即 listen,单请求失败时答 400 并记日志,且不认识任何 harness 概念。其基于 socket 的 Node HTTP 入口可以通过受维护的中间件应用已配置的 gzip,无需新增响应写出服务方法或改变 route 所有者;Web Worker 隧道传递 identity 字节。connection node 半拥有从 `ctx.apiProxy` 经 `toFetchHandler` 绑定到 `/api` 的逻辑。modules node 半(`ClientModuleRegistry`,provide `ctx.clientModules`)拥有单包增量扫描、bundle 路由、启动注入行与 `onRebuilt`/`onGraphChanged` 通知。HMR(热模块替换) node 半通过 `fs.watchFile` membership 与 `/plugins/events` SSE 路由拥有开发期重载。
|
||||
|
||||
**包出口纪律。** modules 包只暴露 `.`(node 半)与 `./client`(完整浏览器半:`ClientModuleSystem`、`parseBootManifest`、收编插件面)——不设专用子路径;wire 类型经根出口 re-export 给 host 侧消费方。收编握手:内核在 cordis 之前把建好的实例写入 `window.__DSH_MODULES__`;`./client` 的 apply 读取该槽位(缺少时显式抛错)并 provide `ctx.modules`。
|
||||
|
||||
@@ -40,3 +40,5 @@ Status: implemented
|
||||
| env 进映射表 | 同一字段将出现 env/json 双源,需再发明优先级 |
|
||||
| create 不等预取(以 `arrive()` 去重为安全依据) | 被 10–25% boot 竞态证伪:在途去重只覆盖同包双拉,不覆盖跨包同步 require 边 |
|
||||
| json 直接当 loader patches 文件 | json 键名将耦合 yml 行结构,profile 编写者要懂 cordis |
|
||||
| 公开响应写出方法并让每条 route 选择接入 | 响应编码属于 Node HTTP 策略;经 `ctx.webServer` 暴露会让每个 route 所有者与测试替身依赖这项策略 |
|
||||
| 手写 gzip 协商与流生命周期 | 受维护的中间件已经处理协商、媒体类型筛选、响应头改写、背压与阈值行为 |
|
||||
|
||||
+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: 8713234735ddb53db27e17a2ea9087de665d86f7
|
||||
2026-08-15-client-shells-and-dynamic-packages.zh.md: ec8a2fb358e86f4fe74db13d555b8be8432ad62e
|
||||
2026-08-15-client-shells-and-dynamic-packages.md: 016314d10f55e0b590e98944ca417bae658ab56a
|
||||
2026-08-15-client-shells-and-dynamic-packages.zh.md: 4e0277d1becab8467521dc21d0e5b7509d1ee993
|
||||
|
||||
+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. Start preloading the content-addressed application batch containing every row except modules.
|
||||
3. Execute one blocking bootstrap batch containing the ordinary modules factory registration.
|
||||
4. Assign `window.__DSH_BOOT__`, including both batch descriptors and every row's individual HMR URL.
|
||||
2. Start preloading every content-addressed application combo URL containing the rows other than modules.
|
||||
3. Execute every blocking bootstrap combo URL; these currently contain the ordinary modules factory registration.
|
||||
4. Assign `window.__DSH_BOOT__`, including all scheduling descriptors and every row's one-resource HMR combo URL.
|
||||
5. Execute the Vite main module.
|
||||
|
||||
The bootstrap batch only registers the modules factory. The startup kernel passes the raw graph and shell seeds to `__ModuleLoader__.create()`. The facade removes the modules registration, materializes it with a `require` function that rejects every external, and invokes its `createClientModuleSystem` export. The modules bundle parses the graph, constructs `ClientModuleSystem`, caches its own exports as the modules row, retains the system in a module closure, and switches the same facade to live mode. The modules client face consequently has a zero-external bootstrap requirement.
|
||||
The bootstrap combo currently registers only the modules factory. The startup kernel passes the raw graph and shell seeds to `__ModuleLoader__.create()`. The facade removes the modules registration, materializes it with a `require` function that rejects every external, and invokes its `createClientModuleSystem` export. The modules bundle parses the graph, constructs `ClientModuleSystem`, caches its own exports as the modules row, retains the system in a module closure, and switches the same facade to live mode. The modules client face consequently has a zero-external bootstrap requirement.
|
||||
|
||||
After the `immediately` tier has registered its factories, the kernel creates all Loader entries, awaits Cordis quiescence, and requires every fiber to be ACTIVE. It then calls `ctx.uiRenderer.mount(container)`. The dynamic `ui-renderer` package owns React, slot rendering, hydration of the existing boot DOM, and the React root lifecycle; the startup kernel and failure page remain React-free.
|
||||
|
||||
@@ -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 package id, and modules must remain self-contained at runtime. Batch generation preserves its ordinary package artifact and gives every other row one shared initial transport; HMR still uses each row's revisioned individual artifact. A missing bootstrap registration fails before Cordis starts; later plugin import, apply, and service-wait failures remain visible through the boot page's ACTIVE scan.
|
||||
The startup protocol depends on the modules package id, and modules must remain self-contained at runtime. Combo generation preserves its ordinary package artifact and gives every other row one shared initial transport; HMR uses the same route with that row as its sole resource. 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. 开始预加载按内容寻址的 application 批次,其中包含 modules 之外的全部 row。
|
||||
3. 执行一个阻塞式 bootstrap 批次,其中包含普通的 modules factory registration。
|
||||
4. 赋值 `window.__DSH_BOOT__`,其中包含两个批次描述及每个 row 的独立 HMR URL。
|
||||
2. 开始预加载所有按内容寻址的 application combo URL,其中包含 modules 之外的 row。
|
||||
3. 执行所有阻塞式 bootstrap combo URL;当前其中包含普通的 modules factory registration。
|
||||
4. 赋值 `window.__DSH_BOOT__`,其中包含全部调度描述及每个 row 的单资源 HMR combo URL。
|
||||
5. 执行 Vite 主模块。
|
||||
|
||||
Bootstrap 批次只登记 modules factory。启动内核把原始图与外壳 seed 传给 `__ModuleLoader__.create()`。Facade 移除 modules registration,用拒绝全部 external 的 `require` 函数将其物化,再调用其 `createClientModuleSystem` 导出。Modules bundle 解析图、构造 `ClientModuleSystem`、把自身 exports 缓存为 modules row、在模块闭包中保留该系统,并把同一 facade 切换到 live 模式。因此 modules client face 必须满足零 external 的自举要求。
|
||||
Bootstrap combo 当前只登记 modules factory。启动内核把原始图与外壳 seed 传给 `__ModuleLoader__.create()`。Facade 移除 modules registration,用拒绝全部 external 的 `require` 函数将其物化,再调用其 `createClientModuleSystem` 导出。Modules bundle 解析图、构造 `ClientModuleSystem`、把自身 exports 缓存为 modules row、在模块闭包中保留该系统,并把同一 facade 切换到 live 模式。因此 modules client face 必须满足零 external 的自举要求。
|
||||
|
||||
`immediately` 层级完成 factory 注册后,内核创建全部 Loader entry,等待 Cordis 静止,并要求每个 fiber 都进入 ACTIVE。随后调用 `ctx.uiRenderer.mount(container)`。动态 `ui-renderer` 包拥有 React、slot 渲染、已有启动 DOM 的 hydrate 和 React root 生命周期;启动内核与失败页保持 React-free。
|
||||
|
||||
@@ -79,7 +79,7 @@ Bootstrap 批次只登记 modules factory。启动内核把原始图与外壳 se
|
||||
|
||||
Npm 依赖在 peer 与开发区段间移动时,bundle 内容保持稳定,因为每个构建 face 都直接声明 external。静态库继续由宿主装配,动态包则保留统一产物与生命周期治理。
|
||||
|
||||
启动协议依赖 modules 的 package id,modules 还必须保持运行期自包含。批次生成保留其普通 package 产物,并为其他全部 row 提供一条共享初始传输;HMR 仍使用每个 row 带 revision 的独立产物。缺少 bootstrap registration 会在 Cordis 启动前失败;后续插件 import、apply 与 service 等待失败仍由启动页的 ACTIVE 扫描呈现。
|
||||
启动协议依赖 modules 的 package id,modules 还必须保持运行期自包含。Combo 生成保留其普通 package 产物,并为其他全部 row 提供一条共享初始传输;HMR 使用同一条路由,并只把该 row 作为资源。缺少 bootstrap registration 会在 Cordis 启动前失败;后续插件 import、apply 与 service 等待失败仍由启动页的 ACTIVE 扫描呈现。
|
||||
|
||||
外壳消费已构建 `lib/` 产品,因此在相关 build 或 watcher 运行前,源码与浏览器产物可能漂移。仅源码 typecheck 通过不能证明实际服务的应用使用同一份代码。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md
|
||||
2026-08-20-webworker-pack-lowering-and-preview.md: 72a6ccf856c95f835e103bd355223bf3cf42f692
|
||||
2026-08-20-webworker-pack-lowering-and-preview.zh.md: 074d44833c34a2999a5c47da809533065201b580
|
||||
2026-08-20-webworker-pack-lowering-and-preview.md: 1ec8fb050445b0a90d8fbf0d97c9ef10cb28b287
|
||||
2026-08-20-webworker-pack-lowering-and-preview.zh.md: 86da66560b509b38ba3ffa49d58035f3e5a173f1
|
||||
|
||||
+2
-2
@@ -10,9 +10,9 @@ The browser worker can neither compile modules at load nor be served by the prod
|
||||
|
||||
## Decision
|
||||
|
||||
**Lowering happens at pack time only.** `@deepseek-ai/dsh-experimental-webworker-packer` composes the profile, materializes the closure, and lowers every JavaScript body; `LOWERING_VERSION` and `WRAPPER_PARAMS` are the pack↔worker contract and live in `src/image-layout.ts` beside the rest of the image layout. The loader wraps bodies exactly as the image holds them: a body still carrying module syntax is a refusal naming the image, and `startWorkerHost` requires the manifest's `lowered` to equal this build's contract before it mounts a single module. `lowerModuleSource` is the transform's only face and the packer its only caller; inside the worker graph, imports name the module that owns the value — never the package barrel, which is the edge that smuggled the parser in.
|
||||
**Lowering happens at pack time only.** `@deepseek-ai/dsh-experimental-webworker-packer` composes the profile, materializes the closure, and lowers every JavaScript body; `LOWERING_VERSION` and `WRAPPER_PARAMS` are the pack↔worker contract and live in `src/image-layout.ts` beside the rest of the image layout. The loader wraps bodies exactly as the image holds them: a body still carrying module syntax is a refusal naming the image, and `startWorkerHost` requires the manifest's `lowered` to equal this build's contract before it mounts a single module. `lowerModuleSource` is the transform's only face and the packer its only caller; inside the worker graph, imports name the module that owns the value — never the package barrel, which is the edge that smuggled the parser in. Source-directory exclusion applies only to workspace and vendored packages whose runtime plane is built `lib/`; installed third-party packages retain JavaScript under `src/` and `dist/` because their published entrypoints may resolve there.
|
||||
|
||||
**The preview is the served page plus one tag.** One Vite build emits `dist/index.html` and `dist/preview.html` sharing every chunk; the only difference is a prepended bootstrap entry whose module connects the worker host. Startup then converges on one protocol: whichever side applies the injection table settles the `__DSH_BOOT_READY__` deferred — the served renderer resolves it in a tail script after the rendered rows, the worker bootstrap installs it before its first await and settles it after the last row — and the client entry awaits it before reading any injected state, so the chain from the stock entry onward is the served chain verbatim. The build uses a relative base so the output mounts under any static directory; the served form anchors deep SPA-fallback paths by rendering `<base href="/">` at serve time, keeping the on-disk pages byte-shared.
|
||||
**The preview is the served page plus one tag.** One Vite build emits `dist/index.html` and `dist/preview.html` sharing every chunk; the only difference is a prepended bootstrap entry whose module connects the worker host. Startup then converges on one protocol: whichever side applies the injection table settles the `__DSH_BOOT_READY__` deferred — the served renderer resolves it in a tail script after the rendered rows, the worker bootstrap installs it before its first await and settles it after the last row — and the client entry awaits it before reading any injected state, so the chain from the stock entry onward is the served chain verbatim. Plugin combo scripts and maps travel through the tunnel; the page-side loader embeds each tunnel-only map as a Base64 data URL before executing its script Blob, preserving indexed-map component names in DevTools without another object-URL lifetime. The build uses a relative base so the output mounts under any static directory; the served form anchors deep SPA-fallback paths by rendering `<base href="/">` at serve time, keeping the on-disk pages byte-shared.
|
||||
|
||||
**The repository preview carries selectable filesystem sources.** The packer emits one base image and a small overlay archive for each named built-in fixture. Without a source query, `preview.html` waits at a chooser for an empty filesystem, the built-in fixtures, or the separately owned WebFS provider. A valid `preview-fixture=none|<built-in-id>` query selects directly and skips the chooser for deterministic browser runs; its distinct name avoids the Client's existing `fixture` transport switch. The Worker mounts the base and then applies the selected overlays in order, restricted to `home/` and `workspace/`, before it validates the base manifest or boots Cordis. `packages/experimental/webworker-runtime/tests/fixtures/vfs-example/` supplies one built-in overlay without giving the packer Session or Workspace knowledge. Its plaintext JSONL logs use the persistence backend's real project/session directory layout, so Session Persistence reads them cold and Workspace Registry derives the Workspace from their `/dsh/workspace` headers. The main Session exceeds the Client's 50-message page and keeps representative tool results at its tail; persisted one-shot and continuable children exercise the subagent catalog. WebFS authorization and user data remain a separate provider and never share this fixture tree.
|
||||
|
||||
|
||||
+2
-2
@@ -10,9 +10,9 @@
|
||||
|
||||
## 决定
|
||||
|
||||
**Lowering 只发生在 pack 期。** `@deepseek-ai/dsh-experimental-webworker-packer` 组合 profile、物化闭包、lower 每个 JavaScript 模块体;`LOWERING_VERSION` 与 `WRAPPER_PARAMS` 是 pack↔worker 的契约,与镜像布局的其余部分一起放在 `src/image-layout.ts`。装载器完全按镜像持有的形态包装模块体:仍带模块语法的模块体是一次点名镜像的拒绝,且 `startWorkerHost` 在挂载任何模块之前要求 manifest 的 `lowered` 等于本构建的契约。`lowerModuleSource` 是转换器唯一的面、packer 是它唯一的调用方;worker 图内部的 import 一律指向拥有该值的模块——绝不指向包 barrel,那正是把解析器偷运进来的那条边。
|
||||
**Lowering 只发生在 pack 期。** `@deepseek-ai/dsh-experimental-webworker-packer` 组合 profile、物化闭包、lower 每个 JavaScript 模块体;`LOWERING_VERSION` 与 `WRAPPER_PARAMS` 是 pack↔worker 的契约,与镜像布局的其余部分一起放在 `src/image-layout.ts`。装载器完全按镜像持有的形态包装模块体:仍带模块语法的模块体是一次点名镜像的拒绝,且 `startWorkerHost` 在挂载任何模块之前要求 manifest 的 `lowered` 等于本构建的契约。`lowerModuleSource` 是转换器唯一的面、packer 是它唯一的调用方;worker 图内部的 import 一律指向拥有该值的模块——绝不指向包 barrel,那正是把解析器偷运进来的那条边。源码目录排除只用于运行期使用已构建 `lib/` 的 workspace 与 vendored 包;已安装第三方包会保留 `src/` 和 `dist/` 下的 JavaScript,因为其发布入口可能解析到这些位置。
|
||||
|
||||
**preview 就是服务页面加一个标签。** 一次 Vite 构建产出共享全部 chunk 的 `dist/index.html` 与 `dist/preview.html`;唯一差异是前插的一个引导入口,其模块负责连接 worker host。启动随之汇于一个协议:应用注入表的一方 settle `__DSH_BOOT_READY__` deferred——served 渲染器在渲染完的行之后用尾部脚本 resolve,worker 引导段在首个 await 之前安装、末行生效后 settle——client 入口在读取任何注入状态前 await 它,因此从标准入口起的链路逐字就是 served 链路。构建使用相对 base,产物可挂载于任意静态目录;served 形态在 serve 期渲染 `<base href="/">` 锚定深层 SPA fallback 路径,磁盘上的两个页面保持字节共享。
|
||||
**preview 就是服务页面加一个标签。** 一次 Vite 构建产出共享全部 chunk 的 `dist/index.html` 与 `dist/preview.html`;唯一差异是前插的一个引导入口,其模块负责连接 worker host。启动随之汇于一个协议:应用注入表的一方 settle `__DSH_BOOT_READY__` deferred——served 渲染器在渲染完的行之后用尾部脚本 resolve,worker 引导段在首个 await 之前安装、末行生效后 settle——client 入口在读取任何注入状态前 await 它,因此从标准入口起的链路逐字就是 served 链路。插件 combo 脚本与 map 都通过 tunnel;页面侧 loader 会在执行脚本 Blob 前,把每个仅 tunnel 可达的 map 内嵌为 Base64 data URL,从而不依赖另一条 object URL 的生命周期,并在 DevTools 中保留 indexed map 的组件名称。构建使用相对 base,产物可挂载于任意静态目录;served 形态在 serve 期渲染 `<base href="/">` 锚定深层 SPA fallback 路径,磁盘上的两个页面保持字节共享。
|
||||
|
||||
**仓库 preview 携带可选择的文件系统来源。** Packer 产出一份基础镜像,并为每套具名内置 fixture 产出一份小型 overlay 归档。没有来源 query 时,`preview.html` 会停在选择面板,可选择空文件系统、内置 fixtures,或归另一实现所有的 WebFS provider。合法的 `preview-fixture=none|<built-in-id>` query 会直接选择并跳过面板,供确定性的浏览器流程使用;该独立名称避开 Client 既有的 `fixture` transport 开关。Worker 先挂载基础镜像,再按顺序把所选 overlays 应用到仅限 `home/` 和 `workspace/` 的路径,随后才校验基础 manifest 并启动 Cordis。`packages/experimental/webworker-runtime/tests/fixtures/vfs-example/` 提供其中一套内置 overlay,Packer 无需理解 Session 或 Workspace。明文 JSONL 日志使用 persistence backend 的真实 project/session 目录布局,因此 Session Persistence 会冷读取它们,Workspace Registry 则根据其 `/dsh/workspace` header 派生 Workspace。主 Session 超过 Client 的 50-message page,并把代表性工具结果留在尾页;持久化的 one-shot 与 continuable child 用于验证 subagent catalog。WebFS 授权与用户数据仍属于独立 provider,绝不与该 fixture 共用目录。
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ External packages that a workspace package resolves at runtime. The tier covers
|
||||
| [`chokidar`](https://github.com/paulmillr/chokidar) | MIT |
|
||||
| [`clsx`](https://github.com/lukeed/clsx) | MIT |
|
||||
| [`commander`](https://github.com/tj/commander.js) | MIT |
|
||||
| [`compression`](https://github.com/expressjs/compression) | MIT |
|
||||
| [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause |
|
||||
| [`e2b`](https://github.com/e2b-dev/e2b) | MIT |
|
||||
| [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT |
|
||||
@@ -81,6 +82,7 @@ External packages that a workspace package resolves at runtime. The tier covers
|
||||
| [`micromark-util-sanitize-uri`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-sanitize-uri) | MIT |
|
||||
| [`micromark-util-symbol`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-symbol) | MIT |
|
||||
| [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT |
|
||||
| [`negotiator`](https://github.com/jshttp/negotiator) | MIT |
|
||||
| [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT |
|
||||
| [`node-pty`](https://github.com/microsoft/node-pty) | MIT |
|
||||
| [`open`](https://github.com/sindresorhus/open) | MIT |
|
||||
@@ -136,8 +138,10 @@ External packages **directly declared** only by repository tooling, test infrast
|
||||
| [`@testing-library/dom`](https://github.com/testing-library/dom-testing-library) | MIT |
|
||||
| [`@testing-library/react`](https://github.com/testing-library/react-testing-library) | MIT |
|
||||
| [`@types/babel__code-frame`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
|
||||
| [`@types/compression`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
|
||||
| [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
|
||||
| [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
|
||||
| [`@types/negotiator`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
|
||||
| [`@types/node`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
|
||||
| [`@types/picomatch`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
|
||||
| [`@types/react`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
|
||||
@@ -153,7 +157,7 @@ External packages **directly declared** only by repository tooling, test infrast
|
||||
| [`cytoscape`](https://github.com/cytoscape/cytoscape.js) | MIT |
|
||||
| [`cytoscape-cose-bilkent`](https://github.com/cytoscape/cytoscape.js-cose-bilkent) | MIT |
|
||||
| [`dayjs`](https://github.com/iamkun/dayjs) | MIT |
|
||||
| [`debug`](https://github.com/debug-js/debug) | MIT |
|
||||
| [`debug`](https://github.com/visionmedia/debug) | MIT |
|
||||
| [`esbuild`](https://github.com/evanw/esbuild) | MIT |
|
||||
| [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only |
|
||||
| [`execa`](https://github.com/sindresorhus/execa) | MIT |
|
||||
|
||||
@@ -85,6 +85,9 @@ function resolveClientExport(packagePath: string, pkg: ClientPackageManifest): s
|
||||
return resolve(dirname(packagePath), relative)
|
||||
}
|
||||
|
||||
const comboUrl = (ids: readonly string[], rev: string): string =>
|
||||
`/plugins/??${ids.map(id => `${id}/client.js`).join(',')}&rev=${rev}`
|
||||
|
||||
/** Derive the assembled browser graph from the same bundle patches and package declarations as `dsh web`. */
|
||||
function loadAssembledPlugins(): readonly AssembledPlugin[] {
|
||||
const entries = appBoot.composeEntries(BUNDLE_LAYERS.map(layer =>
|
||||
@@ -103,7 +106,7 @@ function loadAssembledPlugins(): readonly AssembledPlugin[] {
|
||||
plugins.set(entry.name, {
|
||||
id: entry.name,
|
||||
bundlePath: resolveClientExport(packagePath, pkg),
|
||||
url: `/plugins/${entry.name}/client.js?rev=fx`,
|
||||
url: comboUrl([entry.name], 'fx'),
|
||||
rev: 'fx',
|
||||
...(declaration.inject === undefined ? {} : { inject: declaration.inject }),
|
||||
...(declaration.external === undefined ? {} : { external: declaration.external }),
|
||||
@@ -121,8 +124,6 @@ function loadAssembledPlugins(): readonly AssembledPlugin[] {
|
||||
const PLUGINS = loadAssembledPlugins()
|
||||
|
||||
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 {
|
||||
@@ -138,13 +139,13 @@ function bootGraph(plugins: readonly AssembledPlugin[]): WebBootGraph {
|
||||
batches: [
|
||||
...(bootstrapEntries.length === 0 ? [] : [{
|
||||
phase: 'bootstrap' as const,
|
||||
url: BOOTSTRAP_URL,
|
||||
url: comboUrl(bootstrapEntries, 'fx'),
|
||||
rev: 'fx',
|
||||
entries: bootstrapEntries,
|
||||
}]),
|
||||
...(applicationEntries.length === 0 ? [] : [{
|
||||
phase: 'application' as const,
|
||||
url: APPLICATION_URL,
|
||||
url: comboUrl(applicationEntries, 'fx'),
|
||||
rev: 'fx',
|
||||
entries: applicationEntries,
|
||||
}]),
|
||||
@@ -152,7 +153,7 @@ function bootGraph(plugins: readonly AssembledPlugin[]): WebBootGraph {
|
||||
}
|
||||
}
|
||||
|
||||
/** Build individual and batch script bodies for one fixture composition. */
|
||||
/** Build single-resource and startup combo 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,
|
||||
|
||||
@@ -492,9 +492,14 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
shutdownTimeoutMillis: 1_000,
|
||||
},
|
||||
},
|
||||
// Use an ephemeral port while preserving the shipped compression policy;
|
||||
// a patch replaces the row's complete config.
|
||||
{
|
||||
id: 'webserver',
|
||||
config: { host: '127.0.0.1', port: 0 },
|
||||
config: {
|
||||
host: '127.0.0.1', port: 0, compression: 'gzip',
|
||||
compressionLevel: 1, compressionThresholdBytes: 1024,
|
||||
},
|
||||
},
|
||||
// The bundle's web-runtime row resolves the same built dist under test
|
||||
// (apps/web IS @deepseek-ai/dsh-web-frontend); native browser opening and the
|
||||
|
||||
@@ -193,7 +193,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
// Hold the real application batch so the shell-owned loading page remains observable.
|
||||
const pluginPattern = /\/plugins\/_batch\/application\/[a-f\d]{12}\/client\.js$/
|
||||
const pluginPattern = /\/plugins\/\?\?.+\/client\.js,.+\/client\.js&rev=[a-f\d]{12}$/
|
||||
let releaseBundles = (): void => {}
|
||||
const bundlesReleased = new Promise<void>((resolve) => { releaseBundles = resolve })
|
||||
await page.route(pluginPattern, async (route) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Boots the shipped Web composition over the built dist this lane already uses
|
||||
// and asserts what that composition produces: the model-visible tool catalog
|
||||
// and file-reference guidance plus its retry, sandbox, and approval defaults.
|
||||
// and file-reference guidance plus its HTTP, retry, sandbox, and approval defaults.
|
||||
// No browser and no model call — these are composition facts, and the browser
|
||||
// scenarios in this lane cover the surface itself.
|
||||
import { readFileSync } from 'node:fs'
|
||||
@@ -74,9 +74,15 @@ afterEach(async () => {
|
||||
scaffold = undefined
|
||||
})
|
||||
|
||||
it('assembles the shipped Web catalog, file-reference guidance, retry policy, and confined access default', async () => {
|
||||
it('assembles the shipped Web transport, catalog, guidance, and defaults', async () => {
|
||||
scaffold = await launchWebScaffold({ deepSeekMissingCredential: true })
|
||||
const ctx = scaffold.ctx
|
||||
const index = await fetch(`http://127.0.0.1:${String(ctx.webServer.port)}`, {
|
||||
headers: { 'accept-encoding': 'gzip' },
|
||||
})
|
||||
expect(index.headers.get('content-encoding')).toBe('gzip')
|
||||
expect(index.headers.get('vary')).toContain('Accept-Encoding')
|
||||
await index.body?.cancel()
|
||||
expect(ctx.llm.providerRetryPolicy('deepseek-official')).toMatchInlineSnapshot(`
|
||||
{
|
||||
"initialDelayMs": 500,
|
||||
|
||||
@@ -30,6 +30,8 @@ import { REPO_ROOT, connectFreshWorkspace, newEnglishPage, probeFreePort, requir
|
||||
|
||||
const WEB_SURFACE_PROMPT = fileURLToPath(new URL('./expected/web-runtime-context/web-surface-prompt.expected.md', import.meta.url))
|
||||
|
||||
const comboMapUrl = (url: string): string => url.replace(/\/client\.js(?=,|&rev=)/g, '/client.js.map')
|
||||
|
||||
function waitForReadyLine(child: ChildProcess): Promise<string> {
|
||||
return new Promise((resolveReady, reject) => {
|
||||
let out = ''
|
||||
@@ -286,23 +288,28 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
// request when the matching script node executes; this count pins both.
|
||||
page.on('request', (request) => {
|
||||
const url = new URL(request.url())
|
||||
if (request.resourceType() === 'script' && url.pathname.startsWith('/plugins/')) {
|
||||
pluginScripts.push(url.pathname)
|
||||
const resource = `${url.pathname}${url.search}`
|
||||
if (request.resourceType() === 'script' && resource.startsWith('/plugins/??')) {
|
||||
pluginScripts.push(resource)
|
||||
}
|
||||
})
|
||||
page.on('response', (response) => {
|
||||
const path = new URL(response.url()).pathname
|
||||
if (path.startsWith('/plugins/_batch/')) {
|
||||
cacheHeaders.set(path, response.headers()['cache-control'])
|
||||
const url = new URL(response.url())
|
||||
const resource = `${url.pathname}${url.search}`
|
||||
if (resource.startsWith('/plugins/??')) {
|
||||
cacheHeaders.set(resource, response.headers()['cache-control'])
|
||||
}
|
||||
})
|
||||
await page.goto(readyUrl)
|
||||
await page.getByRole('button', { name: 'New session', exact: true }).first().waitFor({ timeout: 30_000 })
|
||||
const batchPaths = [...new Set(pluginScripts)].sort()
|
||||
expect(batchPaths).toEqual([
|
||||
expect.stringMatching(/^\/plugins\/_batch\/application\/[a-f\d]{12}\/client\.js$/),
|
||||
expect.stringMatching(/^\/plugins\/_batch\/bootstrap\/[a-f\d]{12}\/client\.js$/),
|
||||
])
|
||||
expect(batchPaths).toHaveLength(2)
|
||||
expect(batchPaths).toContainEqual(expect.stringMatching(
|
||||
/^\/plugins\/\?\?.+\/client\.js,.+\/client\.js&rev=[a-f\d]{12}$/,
|
||||
))
|
||||
expect(batchPaths).toContainEqual(expect.stringMatching(
|
||||
/^\/plugins\/\?\?@deepseek-ai\/dsh-client-modules\/client\.js&rev=[a-f\d]{12}$/,
|
||||
))
|
||||
expect([...cacheHeaders.values()]).toEqual([
|
||||
'public, max-age=31536000, immutable',
|
||||
'public, max-age=31536000, immutable',
|
||||
@@ -310,7 +317,7 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
for (const path of batchPaths) {
|
||||
const [scriptResponse, mapResponse] = await Promise.all([
|
||||
fetch(`${readyUrl}${path}`),
|
||||
fetch(`${readyUrl}${path}.map`),
|
||||
fetch(`${readyUrl}${comboMapUrl(path)}`),
|
||||
])
|
||||
expect(scriptResponse.status).toBe(200)
|
||||
expect(mapResponse.status).toBe(200)
|
||||
|
||||
@@ -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/config-catalog.md
|
||||
config-catalog.md: 5991c911d33b34db51c12313036322acf94dcaf9
|
||||
config-catalog.zh.md: 80b8163fc46bccc9509f51730bfcb7e256834f15
|
||||
config-catalog.md: 19f292ee40861ffa6f8e7fdef4d8717189a2149c
|
||||
config-catalog.zh.md: 96f9721cae20a1a0ed172349c0189bc47576c75d
|
||||
|
||||
@@ -833,12 +833,18 @@ Source: [`packages/host/frontend-static/src/index.ts:28`](../packages/host/front
|
||||
## `@deepseek-ai/dsh-host-webserver`
|
||||
|
||||
```ts config-catalog
|
||||
/** Gateway config: the listen address. */
|
||||
/** Web server listen and response-compression config. */
|
||||
export interface Config {
|
||||
/** Listen host; the two supported values are loopback and all-interfaces. */
|
||||
host: '127.0.0.1' | '0.0.0.0'
|
||||
/** Listen port; zero requests an OS-assigned port. */
|
||||
port: number
|
||||
/** Response compression for socket-backed HTTP requests. @default 'none' */
|
||||
compression?: 'none' | 'gzip'
|
||||
/** Gzip DEFLATE level from 0 through 9. @default 1 */
|
||||
compressionLevel?: number
|
||||
/** Minimum known response length eligible for gzip; unknown-length streams are eligible. @default 1024 */
|
||||
compressionThresholdBytes?: number
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -835,12 +835,18 @@ export interface Config {
|
||||
## `@deepseek-ai/dsh-host-webserver`
|
||||
|
||||
```ts config-catalog
|
||||
/** Gateway config: the listen address. */
|
||||
/** Web server listen and response-compression config. */
|
||||
export interface Config {
|
||||
/** Listen host; the two supported values are loopback and all-interfaces. */
|
||||
host: '127.0.0.1' | '0.0.0.0'
|
||||
/** Listen port; zero requests an OS-assigned port. */
|
||||
port: number
|
||||
/** Response compression for socket-backed HTTP requests. @default 'none' */
|
||||
compression?: 'none' | 'gzip'
|
||||
/** Gzip DEFLATE level from 0 through 9. @default 1 */
|
||||
compressionLevel?: number
|
||||
/** Minimum known response length eligible for gzip; unknown-length streams are eligible. @default 1024 */
|
||||
compressionThresholdBytes?: number
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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: 8dae05be292d8d5628c59b768d84dfd97dec1ef2
|
||||
client-modules.zh.md: 3668a4a3958b3a5957f094c0f925edb120d9b8c6
|
||||
client-modules.md: e80329be63c957407df5c9e06fd94780b66459bc
|
||||
client-modules.zh.md: c42f04f3d8be53aa5e5d7cb3c57d06ccd5e21e72
|
||||
|
||||
@@ -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 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.
|
||||
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 versioned one-or-more-resource combo 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 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.
|
||||
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 preloads, bootstrap scripts, 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, unknown members, and entries without exactly one initial combo descriptor.
|
||||
|
||||
```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
|
||||
/** Revisioned individual endpoint used by HMR. */
|
||||
/** Revisioned single-resource combo endpoint used by HMR. */
|
||||
url: string
|
||||
/** Opaque individual-artifact revision used for HMR cache busting. */
|
||||
/** Opaque plugin-artifact revision used for HMR cache busting. */
|
||||
rev: string
|
||||
/** Package-name dependency edges used for factory arrival and plugin composition. */
|
||||
inject?: string[]
|
||||
@@ -36,18 +36,18 @@ interface WebBootEntry {
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Initial script-delivery phase for one content-addressed bundle batch. */
|
||||
/** Initial scheduling phase for one content-addressed combo script. */
|
||||
type WebBootBatchPhase = 'bootstrap' | 'application'
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** One initial-load script containing the factory registrations for several graph rows. */
|
||||
/** One initial combo script; a scheduling phase may span several descriptors. */
|
||||
interface WebBootBatch {
|
||||
/** Parser-blocking bootstrap or preloaded application delivery. */
|
||||
/** Parser-blocking bootstrap or preloaded application scheduling. */
|
||||
phase: WebBootBatchPhase
|
||||
/** Content-addressed batch script endpoint. */
|
||||
/** Content-addressed combo script endpoint. */
|
||||
url: string
|
||||
/** Hash over the batch script and indexed source map. */
|
||||
/** Revision over the combined plugin script bytes and indexed source map. */
|
||||
rev: string
|
||||
/** Graph entry ids whose factories the script registers, in execution order. */
|
||||
entries: string[]
|
||||
@@ -65,12 +65,12 @@ interface WebBootGraph {
|
||||
* unrelated and remains owned by fiber service waiting.
|
||||
*/
|
||||
entries: WebBootEntry[]
|
||||
/** Initial-load batches; every entry belongs to exactly one batch. */
|
||||
/** Initial combo descriptors; every entry belongs to exactly one descriptor. */
|
||||
batches: WebBootBatch[]
|
||||
}
|
||||
```
|
||||
|
||||
Each initial row's `rev` is an opaque process nonce plus sequence, so graph composition does not hash every individual artifact. After HMR observes a change, that row's revision becomes the hash of its new bundle and available source map. The bootstrap batch contains the modules row; the preloaded application batch contains every other row. Batch revisions hash the generated script and indexed source map, and the graph revision hashes both rows and batch descriptors. `immediately` marks the stage-one registration barrier; application rows share one script transport even when only some carry the mark.
|
||||
Each initial row's `rev` is an opaque process nonce plus sequence, so graph composition does not hash every plugin artifact. After HMR observes a change, that row's revision becomes the hash of its new bundle and available source map. The initial descriptors partition rows into bootstrap and application scheduling phases, and either phase may contain several descriptors. Their URLs contain only the ordered package-resource list and revision; phase names do not enter the route. Graph composition preserves row order while greedily splitting before the map-form URL exceeds 3 KiB. Startup combo revisions hash the combined plugin script bytes and indexed source map, and the graph revision hashes both rows and descriptors. `immediately` marks the stage-one registration barrier; rows within one combo share its script transport, while separate combos load independently.
|
||||
|
||||
## The scan
|
||||
|
||||
@@ -82,7 +82,7 @@ Package metadata — including the negative "not a client package" verdict — i
|
||||
|
||||
## The bundle route and index injection
|
||||
|
||||
`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.
|
||||
`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` serves an exact generated combo script; a one-resource request uses the same form and is the HMR path. Its absolute `sourceMappingURL` changes every resource suffix in parallel, yielding `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`. The map is Indexed Source Map v3 even for one resource. An authored component map supplies its section; a component without one receives an identity section whose `sourcesContent` is the generated bundle and whose source name is its packaged `sourceURL` or plugin route. Every startup request URL is at most 3 KiB measured as UTF-8 bytes; partitioning uses the longer map form. All application URLs are preloaded, and all bootstrap URLs execute before the graph global and Vite entry. All advertised responses use long-lived immutable caching. Unknown or altered resource lists, missing revisions, and stale revisions answer 404 rather than serving different bytes or letting the SPA fallback return HTML as JavaScript; other methods are 405. The injection rows carry the current graph on every index render, so a reload always boots against the live composition.
|
||||
|
||||
## The service
|
||||
|
||||
|
||||
@@ -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` 下提供按内容寻址的启动批次与 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)中,不在本页。
|
||||
Web 插件表:[dsh-client-modules](../../packages/client/modules) 中 client 模块系统的 Node 半,以 `ctx.clientModules`(`ClientModuleRegistry`)形式提供。它扫描宿主 Loader 的 entry,找出声明了 `dsh.client` 的包,组合出 `window.__DSH_BOOT__` entry 图,在 `/plugins` 下提供带版本的单资源或多资源 combo 脚本,并以启动协议行回应每次 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` 行与 `WebBootBatch` 描述,随后在 Vite entry 之前向结构化 index 注入表贡献 registration facade、application preload、bootstrap 脚本与图全局量。`global` 行渲染为 `globalThis["__DSH_BOOT__"]`,其中 `<` 已转义,插件可控的字符串因此无法逃出 script 元素。没有有效 manifest 的页面无法启动:浏览器解析器会拒绝畸形 row 或批次、重复 phase 名、未知成员,以及未恰好归属一个初始批次的 entry。
|
||||
图是 Node 半与浏览器半之间协议层的唯一真源。宿主从扫描到的包组合出 `WebBootEntry` 行与 `WebBootBatch` 描述,随后在 Vite entry 之前向结构化 index 注入表贡献 registration facade、application preload、bootstrap 脚本与图全局量。`global` 行渲染为 `globalThis["__DSH_BOOT__"]`,其中 `<` 已转义,插件可控的字符串因此无法逃出 script 元素。没有有效 manifest 的页面无法启动:浏览器解析器会拒绝畸形 row 或批次、未知成员,以及未恰好归属一个初始 combo 描述的 entry。
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
@@ -22,9 +22,9 @@ Web 插件表:[dsh-client-modules](../../packages/client/modules) 中 client
|
||||
interface WebBootEntry {
|
||||
/** Entry name == package name. */
|
||||
id: string
|
||||
/** Revisioned individual endpoint used by HMR. */
|
||||
/** Revisioned single-resource combo endpoint used by HMR. */
|
||||
url: string
|
||||
/** Opaque individual-artifact revision used for HMR cache busting. */
|
||||
/** Opaque plugin-artifact revision used for HMR cache busting. */
|
||||
rev: string
|
||||
/** Package-name dependency edges used for factory arrival and plugin composition. */
|
||||
inject?: string[]
|
||||
@@ -36,18 +36,18 @@ interface WebBootEntry {
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Initial script-delivery phase for one content-addressed bundle batch. */
|
||||
/** Initial scheduling phase for one content-addressed combo script. */
|
||||
type WebBootBatchPhase = 'bootstrap' | 'application'
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** One initial-load script containing the factory registrations for several graph rows. */
|
||||
/** One initial combo script; a scheduling phase may span several descriptors. */
|
||||
interface WebBootBatch {
|
||||
/** Parser-blocking bootstrap or preloaded application delivery. */
|
||||
/** Parser-blocking bootstrap or preloaded application scheduling. */
|
||||
phase: WebBootBatchPhase
|
||||
/** Content-addressed batch script endpoint. */
|
||||
/** Content-addressed combo script endpoint. */
|
||||
url: string
|
||||
/** Hash over the batch script and indexed source map. */
|
||||
/** Revision over the combined plugin script bytes and indexed source map. */
|
||||
rev: string
|
||||
/** Graph entry ids whose factories the script registers, in execution order. */
|
||||
entries: string[]
|
||||
@@ -65,12 +65,12 @@ interface WebBootGraph {
|
||||
* unrelated and remains owned by fiber service waiting.
|
||||
*/
|
||||
entries: WebBootEntry[]
|
||||
/** Initial-load batches; every entry belongs to exactly one batch. */
|
||||
/** Initial combo descriptors; every entry belongs to exactly one descriptor. */
|
||||
batches: WebBootBatch[]
|
||||
}
|
||||
```
|
||||
|
||||
每个初始 row 的 `rev` 都是不透明的进程 nonce 加序号,因此组合图时不会哈希每个独立产物。HMR 观察到变化后,该 row 的 revision 才改为新 bundle 及其可用 sourcemap 的哈希。Bootstrap 批次包含 modules row;预加载的 application 批次包含其他全部 row。批次 revision 对生成的脚本与 indexed sourcemap 求哈希,图 revision 则对 row 与批次描述一并求哈希。`immediately` 标记第一阶段的 registration barrier;即使只有部分 application row 携带该标记,它们仍共享一次脚本传输。
|
||||
每个初始 row 的 `rev` 都是不透明的进程 nonce 加序号,因此组合图时不会哈希每个插件产物。HMR 观察到变化后,该 row 的 revision 才改为新 bundle 及其可用 sourcemap 的哈希。初始描述把 row 划入 bootstrap 与 application 两个调度阶段,每个阶段都可以包含多条描述。URL 只含有序 package 资源列表与 revision,阶段名不会进入路由。图组合保持 row 顺序,并在 map 形式 URL 超过 3 KiB 前贪心切分。启动 combo revision 对合并后的插件脚本字节与 indexed sourcemap 求哈希,图 revision 则对 row 与描述一并求哈希。`immediately` 标记第一阶段的 registration barrier;同一 combo 中的 row 共享脚本传输,不同 combo 则独立加载。
|
||||
|
||||
## 扫描
|
||||
|
||||
@@ -82,7 +82,7 @@ interface WebBootGraph {
|
||||
|
||||
## bundle 路由与 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 渲染时携带当前图,因此重新加载总是基于实时组合启动。
|
||||
`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` 提供精确生成的 combo 脚本;单资源请求采用同一形式,也是 HMR 路径。其绝对 `sourceMappingURL` 平行改写每个资源后缀,得到 `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`。即使只有一个资源,map 仍采用 Indexed Source Map v3。组件有自带 map 时直接用于对应 section;没有时则获得 identity section,其 `sourcesContent` 是构建后 bundle,source 名取打包后的 `sourceURL` 或插件路由。每条启动请求 URL 按 UTF-8 字节计算都不超过 3 KiB;切分按更长的 map 形式计算。所有 application URL 都会预加载,所有 bootstrap URL 都会在图全局量与 Vite entry 之前执行。所有已发布响应都使用长期 immutable 缓存。未知或被修改的资源列表、缺少 revision 及陈旧 revision 都返回 404,绝不提供其他字节,也不会让 SPA fallback 把 HTML 当作 JavaScript 返回;其他方法返回 405。注入行在每次 index 渲染时携带当前图,因此重新加载总是基于实时组合启动。
|
||||
|
||||
## 服务
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/web-server.md
|
||||
web-server.md: c23a48cd57aeb2127107c0487cb46c9202d11605
|
||||
web-server.zh.md: 4097e26ce826067a92de7ba3ec65f790dd6ad1dd
|
||||
web-server.md: 9e1e88d6c796e457fa6c185c1927b46cca9fcf52
|
||||
web-server.zh.md: 4401ccf628360a9571e77ea14ae6c29bd22af151
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](web-server.zh.md)
|
||||
|
||||
[dsh-host-webserver](../../packages/host/webserver) is the browser HTTP carrier for the GUI host: a single `node:http` plugin providing `ctx.webServer`, a named-route registry, index.html transform callbacks, and one fallback handler that a plugin may claim. It is not part of the agent loop and not a capability seam; it knows no harness concepts, and another plugin registers every feature route, including the `/api` bridge, plugin bundles, and the HMR event stream ([layering note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)). It serves browsers only: Electron loads the built files over `file://` and sends fetch requests through an IPC bridge instead of this server.
|
||||
[dsh-host-webserver](../../packages/host/webserver) is the browser HTTP carrier for the GUI host: a single `node:http` plugin providing `ctx.webServer`, a named-route registry, optional gzip response compression, index.html transform callbacks, and one fallback handler that a plugin may claim. It is not part of the agent loop and not a capability seam; it knows no harness concepts, and another plugin registers every feature route, including the `/api` bridge, plugin bundles, and the HMR event stream ([layering note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)). It serves browsers only: Electron loads the built files over `file://` and sends fetch requests through an IPC bridge instead of this server.
|
||||
|
||||
Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts)
|
||||
|
||||
@@ -29,20 +29,26 @@ Match order is fixed: exact table first, then longest matching prefix, then the
|
||||
## Config
|
||||
|
||||
```ts type-equiv
|
||||
/** Gateway config: the listen address. */
|
||||
/** Web server listen and response-compression config. */
|
||||
interface Config {
|
||||
/** Listen host; the two supported values are loopback and all-interfaces. */
|
||||
host: '127.0.0.1' | '0.0.0.0'
|
||||
/** Listen port; zero requests an OS-assigned port. */
|
||||
port: number
|
||||
/** Response compression for socket-backed HTTP requests. @default 'none' */
|
||||
compression?: 'none' | 'gzip'
|
||||
/** Gzip DEFLATE level from 0 through 9. @default 1 */
|
||||
compressionLevel?: number
|
||||
/** Minimum known response length eligible for gzip; unknown-length streams are eligible. @default 1024 */
|
||||
compressionThresholdBytes?: number
|
||||
}
|
||||
```
|
||||
|
||||
`host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); there is no TLS, auth, or origin policy, so a non-loopback bind exposes the server to that network. The dist location is an assembly fact of the frontend plugin that claims the seat.
|
||||
`host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); there is no TLS, auth, or origin policy, so a non-loopback bind exposes the server to that network. `compression` defaults to `none`; the shipped Web bundle selects gzip level 1 with a 1024-byte threshold. The dist location is an assembly fact of the frontend plugin that claims the seat.
|
||||
|
||||
## The service
|
||||
|
||||
`WebServer` (`ctx.webServer`) listens immediately on activation; a listen failure (EADDRINUSE…) rejects initialization, and the boot process reports the failed fiber. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws because route patterns are a composition-level contract and a collision is a misconfiguration. `collectIndexInjections()` gathers structured `IndexInjection` rows over one `webserver/index-inject` emit, and `renderIndex(html)` renders them into successful root and configured index responses before applying the raw `tapIndex(transform)` escape-hatch transforms in registration order; [dsh-client-modules](../../packages/client/modules) answers the event with the boot manifest rows. `port` reads the listening port, including the port assigned by the OS when `config.port` is 0.
|
||||
`WebServer` (`ctx.webServer`) listens immediately on activation; a listen failure (EADDRINUSE…) rejects initialization, and the boot process reports the failed fiber. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws because route patterns are a composition-level contract and a collision is a misconfiguration. Gzip wraps eligible socket-backed responses inside the server, so route handlers retain direct `ServerResponse` ownership and no response-writing API is added to the service. Existing content encodings, `Cache-Control: no-transform`, ranges, SSE, ZIP, and the packaged `.gz` Worker image remain identity responses. `collectIndexInjections()` gathers structured `IndexInjection` rows over one `webserver/index-inject` emit, and `renderIndex(html)` renders them into successful root and configured index responses before applying the raw `tapIndex(transform)` escape-hatch transforms in registration order; [dsh-client-modules](../../packages/client/modules) answers the event with the boot manifest rows. `port` reads the listening port, including the port assigned by the OS when `config.port` is 0.
|
||||
|
||||
A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is logged as a warning and answered 400 — or the socket destroyed when headers are already out — never a process exit. Disposal pairs `close()` with `closeAllConnections()` because a handler may hold its response open (SSE) and such connections never end on their own; without the force-close, teardown would hang. The package never prints: the URL line belongs to the shell. Per-package operational detail, including the dev-mode bundle watch pipeline, stays in the [README](../../packages/host/webserver/README.md).
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](web-server.md) | 中文
|
||||
|
||||
[dsh-host-webserver](../../packages/host/webserver) 是 GUI 宿主的浏览器 HTTP 载体:它是一个提供 `ctx.webServer` 的 `node:http` 插件,包含具名路由注册表、index.html 转换回调,以及一个可由插件认领的回退处理器。它不属于 agent loop(智能体循环),也不是能力 seam;它不了解任何 harness 概念。其他插件负责注册所有功能路由,包括 `/api` 桥接、插件 bundle 和 HMR(热模块替换)事件流([分层说明](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md))。该服务器只服务浏览器:Electron 通过 `file://` 加载已构建文件,并经 IPC 桥接发送 fetch 请求,不使用本服务器。
|
||||
[dsh-host-webserver](../../packages/host/webserver) 是 GUI 宿主的浏览器 HTTP 载体:它是一个提供 `ctx.webServer` 的 `node:http` 插件,包含具名路由注册表、可选的 gzip 响应压缩、index.html 转换回调,以及一个可由插件认领的回退处理器。它不属于 agent loop(智能体循环),也不是能力 seam;它不了解任何 harness 概念。其他插件负责注册所有功能路由,包括 `/api` 桥接、插件 bundle 和 HMR(热模块替换)事件流([分层说明](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md))。该服务器只服务浏览器:Electron 通过 `file://` 加载已构建文件,并经 IPC 桥接发送 fetch 请求,不使用本服务器。
|
||||
|
||||
源码:[`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts)
|
||||
|
||||
@@ -29,20 +29,26 @@ interface WebRoute {
|
||||
## 配置
|
||||
|
||||
```ts type-equiv
|
||||
/** Gateway config: the listen address. */
|
||||
/** Web server listen and response-compression config. */
|
||||
interface Config {
|
||||
/** Listen host; the two supported values are loopback and all-interfaces. */
|
||||
host: '127.0.0.1' | '0.0.0.0'
|
||||
/** Listen port; zero requests an OS-assigned port. */
|
||||
port: number
|
||||
/** Response compression for socket-backed HTTP requests. @default 'none' */
|
||||
compression?: 'none' | 'gzip'
|
||||
/** Gzip DEFLATE level from 0 through 9. @default 1 */
|
||||
compressionLevel?: number
|
||||
/** Minimum known response length eligible for gzip; unknown-length streams are eligible. @default 1024 */
|
||||
compressionThresholdBytes?: number
|
||||
}
|
||||
```
|
||||
|
||||
`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(刻意的网络暴露);没有 TLS、认证或 origin 策略,因此绑定到非回环地址会把服务器暴露给该网络。dist 位置是认领席位的前端插件的组装事实。
|
||||
`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(刻意的网络暴露);没有 TLS、认证或 origin 策略,因此绑定到非回环地址会把服务器暴露给该网络。`compression` 默认为 `none`;随附的 Web 组合选择 gzip level 1 和 1024 字节阈值。dist 位置是认领席位的前端插件的组装事实。
|
||||
|
||||
## 服务
|
||||
|
||||
`WebServer`(`ctx.webServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会使初始化被拒绝,启动进程会报告失败的 fiber。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。`collectIndexInjections()` 经一次 `webserver/index-inject` emit 收集结构化 `IndexInjection` 行,`renderIndex(html)` 把它们渲染进成功的根路径和配置 index 响应,随后再按注册顺序应用原始的 `tapIndex(transform)` 逃生口转换;[dsh-client-modules](../../packages/client/modules) 以启动 manifest(元数据清单)行回应该事件。`port` 读取监听端口,包括 `config.port` 为 0 时操作系统分配的端口。
|
||||
`WebServer`(`ctx.webServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会使初始化被拒绝,启动进程会报告失败的 fiber。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。Gzip 在服务器内部包装符合条件且基于 socket 的响应,因此 route handler 继续直接持有 `ServerResponse`,服务也不新增响应写出 API。已有内容编码、`Cache-Control: no-transform`、范围响应、SSE、ZIP 与打包后的 `.gz` Worker 镜像均保持 identity 响应。`collectIndexInjections()` 经一次 `webserver/index-inject` emit 收集结构化 `IndexInjection` 行,`renderIndex(html)` 把它们渲染进成功的根路径和配置 index 响应,随后再按注册顺序应用原始的 `tapIndex(transform)` 逃生口转换;[dsh-client-modules](../../packages/client/modules) 以启动 manifest(元数据清单)行回应该事件。`port` 读取监听端口,包括 `config.port` 为 0 时操作系统分配的端口。
|
||||
|
||||
处理过程中抛出异常的请求(畸形的 % 转义撞上 `decodeURIComponent`、客户端在请求体中途断开)会记录为警告并应答 400(响应头已发出时则销毁 socket),绝不导致进程退出。dispose(资源释放)把 `close()` 与 `closeAllConnections()` 配对使用,因为处理器可能像 SSE(Server-Sent Events)那样保持响应打开,而这类连接永远不会自行结束;没有强制关闭,拆卸就会挂起。该包从不打印输出:URL 行归 shell 所有。逐包运维细节(含开发模式的 bundle 监视流水线)留在 [README](../../packages/host/webserver/README.zh.md) 中。
|
||||
|
||||
|
||||
@@ -133,6 +133,9 @@
|
||||
config:
|
||||
host: !!js ctx.webStartup.host ?? '127.0.0.1'
|
||||
port: !!js ctx.webStartup.port ?? 3080
|
||||
compression: gzip
|
||||
compressionLevel: 1
|
||||
compressionThresholdBytes: 1024
|
||||
|
||||
# Web glue owned by this bundle: resolves the built frontend dist (an
|
||||
# assembly fact of dsh-web-app, never user config), mounts the
|
||||
|
||||
@@ -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: 089b3ba35780ccb7a24bc8fed10cc0a5353c9eb9
|
||||
README.zh.md: e100dde3ced0f7272e9a75bc4d0a69f6beb4d4ee
|
||||
README.md: 82d203fd9e19590b93ab1f9bf8e2c0673ce1204b
|
||||
README.zh.md: 8b62b4948400c0a91232f897e306792cdf3260e7
|
||||
|
||||
@@ -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 frame revision makes `invalidate` select that plugin's immutable individual URL instead of its initial batch; `prefetch` loads and registers the new factory while the old fiber still serves. The remaining sequence is `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `<style data-plugin>` tags, `entry.refresh()` re-imports and remounts, then `fiber.await()` rethrows startup failures loud. Dependents reload through cordis itself: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber cascades every dependent with zero client-side graph analysis. The node half detects rebuilds with one interval that stat-polls each graph bundle and optional source map from the module host's pre-read baseline. An unchanged startup row begins watching without a content read or hash; a changed row, or a dirty row after its artifact reappears, enters `rebuilt()`, and only real revision changes are broadcast. Any tsdown watch process producing the artifacts therefore triggers HMR with no builder→host channel.
|
||||
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 one-resource combo URL instead of its initial multi-resource URL; `prefetch` loads and registers the new factory while the old fiber still serves. The remaining sequence is `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `<style data-plugin>` tags, `entry.refresh()` re-imports and remounts, then `fiber.await()` rethrows startup failures loud. Dependents reload through cordis itself: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber cascades every dependent with zero client-side graph analysis. The node half detects rebuilds with one interval that stat-polls each graph bundle and optional source map from the module host's pre-read baseline. An unchanged startup row begins watching without a content read or hash; a changed row, or a dirty row after its artifact reappears, enters `rebuilt()`, and only real revision changes are broadcast. Any tsdown watch process producing the artifacts therefore triggers HMR with no builder→host channel.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -18,4 +18,4 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
- **Reload is coarse by design** — a fresh fiber and fresh components; React state inside the reloaded plugin is lost while the data layer (connection/runtime fibers, Session objects) is untouched. react-refresh-grade state preservation conflicts with "re-executing the bundle re-runs the factory" and is deliberately out.
|
||||
- **No failure rollback** — a reload that fails leaves the entry FAILED and visible in the loader status projection; the previous bundle is not restored automatically.
|
||||
- **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.
|
||||
- **The boot graph is not replaced by rebuilt frames** — each frame carries the plugin-artifact revision needed for its one-resource combo reload; a page reload receives the recomposed startup graph.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
为通过脚本加载的客户端插件提供热重载。web 组合包无条件挂载该行;没有重建 watcher(`pnpm run dev:web`)改写客户端 bundle 时,轮询观察不到变化,链路保持空闲。
|
||||
|
||||
浏览器侧订阅系统 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 检测重建,以 module host 读文件前记录的基线 stat-poll 每个图 bundle 及其可选 sourcemap。未变化的启动 row 无需读取内容或求哈希即可开始监视;只有发生变化的 row,或产物恢复后的 dirty row,才会进入 `rebuilt()`,并且只广播真实 revision 变更。因此,任何生成这些产物的 tsdown watch 进程都能触发 HMR(热模块替换),无需 builder→host 通道。
|
||||
浏览器侧订阅系统 SSE(Server-Sent Events)通道(`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行。帧内 revision 会让 `invalidate` 选择该插件不可变的单资源 combo URL,而不是初始多资源 URL;`prefetch` 在旧 fiber 仍在服务时加载并登记新 factory。其余顺序是:`registry.delete`(在 fiber dispose(资源释放)之前执行:仅 dispose fiber 会触发 vendored Loader 的 self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签、通过 `entry.refresh()` 重新导入并挂载,最后通过 `fiber.await()` 直接重新抛出启动失败。依赖方由 Cordis 自身重载:fiber 的激活 epoch 会串联其服务提供方的 uid,因此替换提供方 fiber 会级联所有依赖方,无需客户端图分析。node 侧使用一个 interval 检测重建,以 module host 读文件前记录的基线 stat-poll 每个图 bundle 及其可选 sourcemap。未变化的启动 row 无需读取内容或求哈希即可开始监视;只有发生变化的 row,或产物恢复后的 dirty row,才会进入 `rebuilt()`,并且只广播真实 revision 变更。因此,任何生成这些产物的 tsdown watch 进程都能触发 HMR(热模块替换),无需 builder→host 通道。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -18,4 +18,4 @@
|
||||
|
||||
- **重载有意保持粗粒度**:会创建全新的 fiber 和组件;重载插件中的 React 状态会丢失,数据层(连接 fiber、运行时 fiber 和 Session 对象)不受影响。react-refresh 级状态保留与「重新执行组合包会重新运行 factory」冲突,因此有意排除。
|
||||
- **失败时不回滚**:失败的重载会使配置项处于 FAILED 状态,并在 loader 状态投影中显示;系统不会自动恢复先前组合包。
|
||||
- **重建帧不会替换启动图**:每个帧都会携带本次重载所需的独立脚本 revision;页面重载时才接收重新组合的批次图。
|
||||
- **重建帧不会替换启动图**:每个帧都会携带单资源 combo 重载所需的插件产物 revision;页面重载时才接收重新组合的启动图。
|
||||
|
||||
@@ -152,7 +152,7 @@ export function apply(ctx: Context): void {
|
||||
break
|
||||
case 'graph':
|
||||
// Connect-time snapshot, unused. Each rebuilt frame carries the
|
||||
// revision that selects the immutable individual script; the boot
|
||||
// revision that selects the immutable single-resource combo script; the boot
|
||||
// graph remains the initial-load record until a page reload.
|
||||
break
|
||||
default:
|
||||
|
||||
@@ -57,7 +57,7 @@ function fakeClientModuleHost(rows: Map<string, string>, options: FakeHostOption
|
||||
options.beforeGraphRead?.()
|
||||
return {
|
||||
rev: 'r',
|
||||
entries: [...rows.keys()].map(id => ({ id, url: `/plugins/${id}/client.js?rev=r`, rev: 'r' })),
|
||||
entries: [...rows.keys()].map(id => ({ id, url: `/plugins/??${id}/client.js&rev=r`, rev: 'r' })),
|
||||
batches: [],
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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: 2e8ed59614a30a1c737e57e5e5869bccf6bfb6db
|
||||
README.zh.md: 982d4a444f691cb575b77d35283e39940b66b42a
|
||||
README.md: cb41158c29e96de722522a6fae2fa4d959671daa
|
||||
README.zh.md: 1c47b23430b39397e572f29c5d60f0210e4ad9e5
|
||||
|
||||
@@ -6,11 +6,11 @@ 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__`, 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.
|
||||
The Host installs `window.__ModuleLoader__`, preloads every application combo script, then executes the parser-blocking bootstrap combo scripts. `bootstrap` and `application` are scheduling phases, not URL path names, and either phase may span several requests. 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 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.
|
||||
Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → exports; graph row (`window.__DSH_BOOT__`) → register its initial-combo factory; registered factory → materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the asynchronous graph-row load and records observed edges into the module record. `prefetch` is the stage-one arrival hook; rows sharing an initial combo URL share one in-flight script task. `invalidate(id, rev)` drops a non-bootstrap factory and materialized record and switches that row to its revisioned one-resource combo URL, so HMR reloads one plugin without executing the initial multi-resource script again.
|
||||
|
||||
The Node half scans enabled Loader entries for web `dsh.client` packages, resolves and snapshots each `exports["./client"]` plus its available source map, carries package-specific `dsh.client.external` requests, and orders dynamic providers before consumers. It emits a bootstrap batch for the modules row and an application batch for every other row. Each batch has a content-addressed script and an indexed Source Map v3 file assembled from the available plugin maps. Initial individual revisions are opaque process nonces, so startup does not hash every plugin; HMR hashes only an artifact whose watcher reports a change. Individual revisioned scripts and maps remain available for HMR; every versioned response is immutable, and a revision mismatch returns 404 instead of serving newer bytes under an older URL. Source launch maps host imports to TypeScript source but still consumes these built client exports; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
|
||||
The Node half scans enabled Loader entries for web `dsh.client` packages, resolves and snapshots each `exports["./client"]` plus its available source map, carries package-specific `dsh.client.external` requests, and orders dynamic providers before consumers. It schedules the modules row as bootstrap and every other row as application. Both initial delivery and HMR use the same combo route: `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` for any non-empty resource list, including a one-resource HMR reload. Within each phase, graph composition greedily partitions the ordered resources before the longer map-form URL would exceed 3 KiB, so a large application uses several requests without changing the path model. A script's absolute `sourceMappingURL` mirrors its list as `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`. Every generated map is Indexed Source Map v3. An authored plugin map supplies its section; otherwise an identity section embeds the generated bundle and names its packaged `lib/client.js` when available, so one combo remains separable into its component files. Initial per-plugin revisions are opaque process nonces, so startup does not hash every plugin; HMR hashes only an artifact whose watcher reports a change. Every advertised response is immutable, and an unadvertised combination or revision returns 404 instead of serving different bytes. Source launch maps host imports to TypeScript source but still consumes these built client exports; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
|
||||
|
||||
`dsh.client.external` is an optional exact-specifier request list beyond the implicit baseline of shell-seeded React, Cordis, and static UI libraries. A request is answered by the dynamic package row it names or an exact static-table key; only a trailing `/client` aliases a package row, and there is no provider-alias declaration. Type-only imports are erased and create no request. Composition rejects malformed requests, missing suppliers, self-requests, and synchronous request cycles; import and prefetch recursively register dynamic suppliers before their consumers materialize. See [shared modules and the module graph](../AGENTS.md#shared-modules-and-the-module-graph).
|
||||
|
||||
@@ -26,4 +26,4 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
- **Flat module graph by design** — every bundle is one module node whose edges point only at table leaves; the interface (`loadCache`/`edges`/`invalidate`) already supports a general module graph, so the externalization granularity can change without an interface change.
|
||||
- **No unload bookkeeping of its own** — style removal and fiber teardown ordering live with the HMR driver (`@deepseek-ai/dsh-client-hmr`); the loader only inventories owned style tag ids per record.
|
||||
- **Snapshot delivery retains artifact bytes** — the Host holds each bundle, optional source map, revision-stamped individual response, and generated batch in memory; HMR additionally retains one prior batch generation. Memory scales as several copies of the composed client artifacts in exchange for immutable responses and one-generation race tolerance.
|
||||
- **Snapshot delivery retains artifact bytes** — the Host holds each bundle, optional source map, generated one-resource response, and current startup combo responses in memory; HMR additionally retains one prior startup generation. Memory scales as several copies of the composed client artifacts in exchange for immutable responses and one-generation race tolerance.
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
|
||||
惰性 CJS 模型(web2):执行插件 bundle 只会注册其 factory(`window.__ModuleLoader__.load({id, factory})`);每个模块主体的副作用(包括 CSS 注入)都位于 factory 闭包中,在物化时运行(`factory(require)` → 导出表层,并在 `loadCache` 中记忆化),不会在脚本执行时运行。如果 factory 依赖另一个已注册但尚未物化的模块,系统会递归物化它;图组合会把声明的动态请求提供方放在消费者之前,而 require 循环会抛出异常,因为 factory 形式的 CJS 无法提供部分导出。`<id>/client` 与裸 id 指向同一表层(一个插件 bundle 就是其包的客户端侧)。
|
||||
|
||||
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`,无需另一个页面全局变量。
|
||||
Host 会先安装 `window.__ModuleLoader__`、预加载所有 application combo 脚本,再执行阻塞 parser 的 bootstrap combo 脚本。`bootstrap` 与 `application` 只是调度阶段,不会成为 URL 路径名,并且每个阶段都可以包含多次请求。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__`)→ 登记其初始批次 factory;已登记 factory → 物化;其他情况一律抛出异常。这是构建时 bundle 纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含异步 graph-row 加载分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段到达钩子;共享同一批次 URL 的 row 会共享一个进行中的脚本任务。`invalidate(id, rev)` 会丢弃非 bootstrap factory 与物化记录,并让该 row 改用带 revision 的独立脚本,因此 HMR(热模块替换)只重载一个插件,不会再次执行整批脚本。
|
||||
解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 导出;模块图记录(`window.__DSH_BOOT__`)→ 登记其初始 combo 中的 factory;已登记 factory → 物化;其他情况一律抛出异常。这是构建时 bundle 纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含异步 graph-row 加载分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段到达钩子;共享同一初始 combo URL 的 row 会共享一个进行中的脚本任务。`invalidate(id, rev)` 会丢弃非 bootstrap factory 与物化记录,并让该 row 改用带 revision 的单资源 combo URL,因此 HMR(热模块替换)只重载一个插件,不会再次执行初始多资源脚本。
|
||||
|
||||
Node 侧会扫描已启用的 Loader 配置项以发现 web `dsh.client` 包,解析并快照每个 `exports["./client"]` 及其可用 sourcemap,携带包专属 `dsh.client.external` 请求,并把动态提供方排在消费者之前。它为 modules row 生成 bootstrap 批次,为其余 row 生成 application 批次;每个批次都有按内容寻址的脚本,以及由现有插件 map 组合而成的 indexed Source Map v3 文件。初始独立 revision 使用不透明的进程 nonce,因此启动时不会哈希每个插件;HMR 只哈希 watcher 报告发生变化的产物。HMR 仍可访问带 revision 的独立脚本与 map;所有版本化响应都不可变,revision 不匹配时返回 404,绝不在旧 URL 下提供新字节。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费这些构建后的客户端导出;缺失文件共享一条构建说明,随后以包/路径列表列出各项,而无关的文件系统错误仍是独立故障。
|
||||
Node 侧会扫描已启用的 Loader 配置项以发现 web `dsh.client` 包,解析并快照每个 `exports["./client"]` 及其可用 sourcemap,携带包专属 `dsh.client.external` 请求,并把动态提供方排在消费者之前。它把 modules row 调度为 bootstrap,把其余 row 调度为 application。初始传输与 HMR 使用同一条 combo 路由:任意非空资源列表都写成 `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>`,HMR 单资源重载也不例外。图组合会在更长的 map 形式 URL 超过 3 KiB 前,按顺序贪心切分每个阶段的资源,因此大型 application 会产生多次请求,但路径模型不变。脚本中的绝对 `sourceMappingURL` 把各自的资源列表平行改写成 `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`。每个生成的 map 都是 Indexed Source Map v3:插件有自带 map 时直接用于对应 section;没有时则生成 identity section,内嵌构建后 bundle,并在存在打包路径时命名为其 `lib/client.js`,因此一个 combo 仍能拆回各组件文件。初始逐插件 revision 使用不透明的进程 nonce,因此启动时不会哈希每个插件;HMR 只哈希 watcher 报告发生变化的产物。每个已发布响应都不可变,未发布的组合或 revision 返回 404,绝不提供其他字节。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费这些构建后的客户端导出;缺失文件共享一条构建说明,随后以包/路径列表列出各项,而无关的文件系统错误仍是独立故障。
|
||||
|
||||
`dsh.client.external` 是外壳播种的 React、Cordis 和静态 UI 库这一统一基座之外的可选精确 specifier 请求列表。请求由其命名的动态 package row 或精确静态表键回答;只有末尾 `/client` 会别名到 package row,并且不存在 provider 别名声明。纯类型 import 会被擦除,不产生请求。组合阶段会拒绝畸形请求、缺失提供方、自请求和同步请求环;import 与 prefetch 会在消费者物化前递归登记动态提供方。参见[共享模块与模块图](../AGENTS.md#shared-modules-and-the-module-graph)。
|
||||
|
||||
@@ -26,4 +26,4 @@ Node 侧会扫描已启用的 Loader 配置项以发现 web `dsh.client` 包,
|
||||
|
||||
- **有意采用扁平模块图**:每个 bundle 是一个模块节点,其边只指向表中的叶节点;接口(`loadCache`/`edges`/`invalidate`)已经支持通用模块图,因此可以改变 externalization 粒度而不更改接口。
|
||||
- **自身不维护卸载记录**:样式移除与 fiber 拆卸顺序属于 HMR 驱动器(`@deepseek-ai/dsh-client-hmr`);loader 只在每条记录中登记其拥有的样式标签 id。
|
||||
- **快照式提供会常驻产物字节**:Host 会在内存中保留每个 bundle、可选 sourcemap、带 revision 的独立响应及生成的批次;HMR 还会保留上一代批次。内存会随组合出的客户端产物增长为数份副本,以换取 immutable 响应和一代竞态容忍。
|
||||
- **快照式提供会常驻产物字节**:Host 会在内存中保留每个 bundle、可选 sourcemap、生成的单资源响应及当前启动 combo 响应;HMR 还会保留上一代启动响应。内存会随组合出的客户端产物增长为数份副本,以换取 immutable 响应和一代竞态容忍。
|
||||
|
||||
@@ -50,9 +50,9 @@ declare module '@deepseek-ai/cordis' {
|
||||
export interface WebBootEntry {
|
||||
/** Entry name == package name. */
|
||||
id: string
|
||||
/** Revisioned individual endpoint used by HMR. */
|
||||
/** Revisioned single-resource combo endpoint used by HMR. */
|
||||
url: string
|
||||
/** Opaque individual-artifact revision used for HMR cache busting. */
|
||||
/** Opaque plugin-artifact revision used for HMR cache busting. */
|
||||
rev: string
|
||||
/** Package-name dependency edges used for factory arrival and plugin composition. */
|
||||
inject?: string[]
|
||||
@@ -62,16 +62,16 @@ export interface WebBootEntry {
|
||||
external?: string[]
|
||||
}
|
||||
|
||||
/** Initial script-delivery phase for one content-addressed bundle batch. */
|
||||
/** Initial scheduling phase for one content-addressed combo script. */
|
||||
export type WebBootBatchPhase = 'bootstrap' | 'application'
|
||||
|
||||
/** One initial-load script containing the factory registrations for several graph rows. */
|
||||
/** One initial combo script; a scheduling phase may span several descriptors. */
|
||||
export interface WebBootBatch {
|
||||
/** Parser-blocking bootstrap or preloaded application delivery. */
|
||||
/** Parser-blocking bootstrap or preloaded application scheduling. */
|
||||
phase: WebBootBatchPhase
|
||||
/** Content-addressed batch script endpoint. */
|
||||
/** Content-addressed combo script endpoint. */
|
||||
url: string
|
||||
/** Hash over the batch script and indexed source map. */
|
||||
/** Revision over the combined plugin script bytes and indexed source map. */
|
||||
rev: string
|
||||
/** Graph entry ids whose factories the script registers, in execution order. */
|
||||
entries: string[]
|
||||
@@ -87,7 +87,7 @@ export interface WebBootGraph {
|
||||
* unrelated and remains owned by fiber service waiting.
|
||||
*/
|
||||
entries: WebBootEntry[]
|
||||
/** Initial-load batches; every entry belongs to exactly one batch. */
|
||||
/** Initial combo descriptors; every entry belongs to exactly one descriptor. */
|
||||
batches: WebBootBatch[]
|
||||
}
|
||||
|
||||
@@ -95,11 +95,11 @@ export interface WebBootGraph {
|
||||
export interface BootModuleRow {
|
||||
/** Entry name == package name (module-table key). */
|
||||
id: string
|
||||
/** Revisioned individual endpoint used after HMR invalidation. */
|
||||
/** Revisioned single-resource combo endpoint used after HMR invalidation. */
|
||||
url: string
|
||||
/** Content-addressed batch endpoint used before the first HMR invalidation. */
|
||||
/** Content-addressed combo endpoint used before the first HMR invalidation. */
|
||||
initialUrl: string
|
||||
/** Opaque individual-artifact revision used after HMR invalidation. */
|
||||
/** Opaque plugin-artifact revision used after HMR invalidation. */
|
||||
rev: string
|
||||
/** Injected package rows whose factories arrive before this row materializes. */
|
||||
inject: string[]
|
||||
@@ -214,7 +214,7 @@ export function parseBootManifest(wire: unknown): BootManifest {
|
||||
|
||||
const entryIds = new Set(moduleFields.map(row => row.id))
|
||||
const initialUrls = new Map<string, string>()
|
||||
const phases = new Set<WebBootBatchPhase>()
|
||||
const batchUrls = new Set<string>()
|
||||
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')
|
||||
@@ -224,13 +224,13 @@ export function parseBootManifest(wire: unknown): BootManifest {
|
||||
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`)
|
||||
}
|
||||
if (batchUrls.has(batch.url)) {
|
||||
throw new Error(`client-modules: boot manifest carries duplicate batch URL ${JSON.stringify(batch.url)}`)
|
||||
}
|
||||
batchUrls.add(batch.url)
|
||||
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`)
|
||||
@@ -352,9 +352,9 @@ 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 its individual
|
||||
* script rather than the initial batch. The bootstrap module remains
|
||||
* materialized.
|
||||
* materialized record so the next prefetch/import loads its one-resource
|
||||
* combo script rather than the initial multi-resource request. 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.
|
||||
|
||||
@@ -28,13 +28,10 @@ const defaultLoadBundle = (url: string): Promise<void> => new Promise((resolve,
|
||||
|
||||
/** Replace the rev query while preserving absolute, protocol-relative, or path-relative form. */
|
||||
function atRevision(url: string, rev: string): string {
|
||||
const absolute = /^[A-Za-z][A-Za-z\d+.-]*:/.test(url)
|
||||
const protocolRelative = url.startsWith('//')
|
||||
const parsed = new URL(url, 'http://dsh.invalid')
|
||||
parsed.searchParams.set('rev', rev)
|
||||
if (absolute) return parsed.href
|
||||
if (protocolRelative) return `//${parsed.host}${parsed.pathname}${parsed.search}${parsed.hash}`
|
||||
return `${parsed.pathname}${parsed.search}${parsed.hash}`
|
||||
if (!/[?&]rev=[^&#]*/.test(url)) {
|
||||
throw new Error(`client-modules: bundle URL ${url} has no revision`)
|
||||
}
|
||||
return url.replace(/([?&]rev=)[^&#]*/, `$1${encodeURIComponent(rev)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,7 +68,7 @@ export class ClientModuleSystem implements ClientModuleLoader {
|
||||
private readonly bootstrapIds = new Set<string>()
|
||||
/** 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. */
|
||||
/** Single-resource combo 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>()
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* 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 two initial-load
|
||||
* batches plus revisioned per-plugin HMR scripts and their source maps,
|
||||
* contributes the registration facade, application preload, bootstrap script,
|
||||
* in `./client/manifest.ts`) in module-graph order, serves one-or-more-plugin
|
||||
* combo scripts plus their source maps,
|
||||
* contributes the registration facade, application preloads, bootstrap scripts,
|
||||
* and graph to the webserver's index injection table, and provides the
|
||||
* `clientModuleHost` service (the HMR node half's registration/notification
|
||||
* face).
|
||||
@@ -138,24 +138,38 @@ interface WebPluginRecord {
|
||||
bundle: Buffer
|
||||
/** Pre-read filesystem baseline handed to the HMR watcher. */
|
||||
baseline: ClientArtifactBaseline
|
||||
/** Revision-stamped individual response used after HMR invalidation. */
|
||||
individualBundle: Buffer
|
||||
/** Optional parsed and original source map snapshot for immutable delivery. */
|
||||
/** Optional authored source map snapshot; generated-file identity mapping is the fallback. */
|
||||
sourceMap?: { body: Buffer; parsed: Record<string, unknown> }
|
||||
}
|
||||
|
||||
/** One generated initial-load response and its wire descriptor. */
|
||||
interface BatchArtifact {
|
||||
descriptor: WebBootBatch
|
||||
/** Fields shared by every generated combo response. */
|
||||
interface ComboArtifactBase {
|
||||
url: string
|
||||
rev: string
|
||||
entries: string[]
|
||||
script: Buffer
|
||||
sourceMap?: Buffer
|
||||
}
|
||||
|
||||
/** One generated combo response over an ordered list of plugin resources. */
|
||||
interface ComboArtifact extends ComboArtifactBase {
|
||||
sourceMap: Buffer
|
||||
sourceMapUrl: string
|
||||
}
|
||||
|
||||
/** One generated initial-load response and its wire descriptor. */
|
||||
type BatchArtifact = ComboArtifact & { descriptor: WebBootBatch }
|
||||
|
||||
/** Versioned code is immutable; mismatched revisions are rejected instead of serving newer bytes. */
|
||||
const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable'
|
||||
/** Generated request URLs stay below conservative browser and intermediary request-target limits. */
|
||||
const MAX_COMBO_URL_BYTES = 3 * 1024
|
||||
const HASH_REVISION_LENGTH = 12
|
||||
const COMBO_REVISION_PLACEHOLDER = '0'.repeat(HASH_REVISION_LENGTH)
|
||||
|
||||
/** Source-map trailer emitted by tsdown at the end of every client bundle. */
|
||||
const SOURCE_MAP_TRAILER = /(?:\r?\n)?\/\/# sourceMappingURL=[^\r\n]*(?:\r?\n)?$/
|
||||
/** Debugger source name appended to page bundles in the WebWorker image. */
|
||||
const SOURCE_URL_TRAILER = /(?:\r?\n)?\/\/# sourceURL=([^\r\n]+)(?:\r?\n)?$/
|
||||
|
||||
/** Narrow an unknown parsed JSON value to the `dsh.client` declaration, throwing on malformed fields. */
|
||||
function parseDshClient(pkgName: string, value: unknown): DshClientDeclaration | undefined {
|
||||
@@ -193,33 +207,86 @@ function clientExportOf(pkgName: string, exportsField: unknown): string | undefi
|
||||
throw new Error(`client-modules: ${pkgName} exports["./client"] must be a string or an object with a string default`)
|
||||
}
|
||||
|
||||
/** sha1 content hash shortened to 12 hex chars (batch / graph / rebuilt-artifact rev). */
|
||||
/** sha1 content hash shortened to 12 hex chars (combo / graph / rebuilt-artifact rev). */
|
||||
function shortHash(input: string | Buffer): string {
|
||||
return createHash('sha1').update(input).digest('hex').slice(0, 12)
|
||||
return createHash('sha1').update(input).digest('hex').slice(0, HASH_REVISION_LENGTH)
|
||||
}
|
||||
|
||||
/** Hash several response fields without allowing bytes to move across field boundaries. */
|
||||
function framedHash(domain: string, parts: readonly Buffer[]): string {
|
||||
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)
|
||||
return hash.digest('hex').slice(0, HASH_REVISION_LENGTH)
|
||||
}
|
||||
|
||||
/** Hash every byte served after HMR observes one artifact change. */
|
||||
/** Hash every artifact input served after HMR observes one plugin change. */
|
||||
function artifactRevision(bundle: Buffer, sourceMap: WebPluginRecord['sourceMap']): string {
|
||||
return framedHash('individual', sourceMap === undefined ? [bundle] : [bundle, sourceMap.body])
|
||||
return framedHash('plugin-artifact', 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`
|
||||
/** Address one ordered plugin-file list through the shared combo route. */
|
||||
function comboUrl(ids: readonly string[], rev: string, sourceMap = false): string {
|
||||
const resources = ids.map(id => `${id}/client.js${sourceMap ? '.map' : ''}`).join(',')
|
||||
return `/plugins/??${resources}&rev=${rev}`
|
||||
}
|
||||
|
||||
/** 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)
|
||||
/** Measure the longer map-form URL used to partition a startup resource list. */
|
||||
function projectedComboUrlBytes(records: readonly WebPluginRecord[]): number {
|
||||
return Buffer.byteLength(comboUrl(
|
||||
records.map(record => record.entry.id),
|
||||
COMBO_REVISION_PLACEHOLDER,
|
||||
true,
|
||||
))
|
||||
}
|
||||
|
||||
/** Partition one phase in graph order without allowing a generated URL above the protocol limit. */
|
||||
function partitionComboRecords(records: readonly WebPluginRecord[]): WebPluginRecord[][] {
|
||||
const chunks: WebPluginRecord[][] = []
|
||||
let current: WebPluginRecord[] = []
|
||||
for (const record of records) {
|
||||
const candidate = [...current, record]
|
||||
if (projectedComboUrlBytes(candidate) <= MAX_COMBO_URL_BYTES) {
|
||||
current = candidate
|
||||
continue
|
||||
}
|
||||
if (current.length === 0) {
|
||||
throw new Error(
|
||||
`client-modules: ${record.entry.id} exceeds the ${String(MAX_COMBO_URL_BYTES)}-byte combo URL limit`,
|
||||
)
|
||||
}
|
||||
chunks.push(current)
|
||||
current = [record]
|
||||
if (projectedComboUrlBytes(current) > MAX_COMBO_URL_BYTES) {
|
||||
throw new Error(
|
||||
`client-modules: ${record.entry.id} exceeds the ${String(MAX_COMBO_URL_BYTES)}-byte combo URL limit`,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (current.length > 0) chunks.push(current)
|
||||
return chunks
|
||||
}
|
||||
|
||||
/** Executable source plus the generated-file name used when no authored map exists. */
|
||||
interface ComboSource {
|
||||
source: string
|
||||
fallbackSource: string
|
||||
}
|
||||
|
||||
/** Remove bundle-local debug directives and retain their stable generated-file name. */
|
||||
function comboSource(record: WebPluginRecord): ComboSource {
|
||||
let source = record.bundle.toString('utf8')
|
||||
const sourceUrl = SOURCE_URL_TRAILER.exec(source)?.[1]
|
||||
source = source.replace(SOURCE_URL_TRAILER, '').replace(SOURCE_MAP_TRAILER, '')
|
||||
if (!source.endsWith('\n')) source += '\n'
|
||||
const fallbackSource = sourceUrl === undefined
|
||||
? `/plugins/${record.entry.id}/client.js`
|
||||
: /^(?:[A-Za-z][A-Za-z\d+.-]*:|\/)/.test(sourceUrl) ? sourceUrl : `/${sourceUrl}`
|
||||
return { source, fallbackSource }
|
||||
}
|
||||
|
||||
/** Stamp a combo script's absolute indexed-map URL onto its executable bytes. */
|
||||
function comboScript(input: string, sourceMapUrl?: string): Buffer {
|
||||
return Buffer.from(sourceMapUrl === undefined ? input : `${input}//# sourceMappingURL=${sourceMapUrl}\n`)
|
||||
}
|
||||
|
||||
/** Parse an optional source-map artifact; missing maps do not prevent plugin execution. */
|
||||
@@ -254,8 +321,8 @@ function newlineCount(value: string): number {
|
||||
return count
|
||||
}
|
||||
|
||||
/** Resolve section sources against their original per-plugin map URL before relocation into a batch. */
|
||||
function batchSectionMap(record: WebPluginRecord): Record<string, unknown> {
|
||||
/** Resolve section sources against their original per-plugin map URL before combo relocation. */
|
||||
function comboSectionMap(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}`)
|
||||
@@ -274,34 +341,49 @@ function batchSectionMap(record: WebPluginRecord): Record<string, unknown> {
|
||||
return section
|
||||
}
|
||||
|
||||
/** Concatenate factory registrations and compose their maps as indexed sections. */
|
||||
function buildBatch(phase: WebBootBatchPhase, records: readonly WebPluginRecord[]): BatchArtifact {
|
||||
/** Map each generated line to the same line in a bundled JavaScript source. */
|
||||
function identitySectionMap(source: string, sourceUrl: string): Record<string, unknown> {
|
||||
const mappings = Array.from({ length: newlineCount(source) }, (_, index) => index === 0 ? 'AAAA' : 'AACA')
|
||||
.join(';')
|
||||
return {
|
||||
version: 3,
|
||||
names: [],
|
||||
sources: [sourceUrl],
|
||||
sourcesContent: [source],
|
||||
mappings,
|
||||
}
|
||||
}
|
||||
|
||||
/** Concatenate one or more factory registrations and compose their maps as indexed sections. */
|
||||
function buildCombo(records: readonly WebPluginRecord[], revision?: string): ComboArtifact {
|
||||
let source = ''
|
||||
const sections: { offset: { line: number; column: 0 }; map: Record<string, unknown> }[] = []
|
||||
let line = 0
|
||||
for (const record of records) {
|
||||
if (record.sourceMap !== undefined) {
|
||||
sections.push({ offset: { line, column: 0 }, map: batchSectionMap(record) })
|
||||
}
|
||||
const bundle = `${withoutSourceMapTrailer(record.bundle)};\n`
|
||||
const prepared = comboSource(record)
|
||||
const section = record.sourceMap === undefined
|
||||
? identitySectionMap(prepared.source, prepared.fallbackSource)
|
||||
: comboSectionMap(record)
|
||||
sections.push({ offset: { line, column: 0 }, map: section })
|
||||
const bundle = `${prepared.source};\n`
|
||||
source += bundle
|
||||
line += newlineCount(bundle)
|
||||
}
|
||||
const sourceMap = sections.length === 0
|
||||
? undefined
|
||||
: Buffer.from(`${JSON.stringify({ version: 3, file: 'client.js', sections })}\n`)
|
||||
if (sourceMap !== undefined) source += '//# sourceMappingURL=client.js.map\n'
|
||||
const script = Buffer.from(source)
|
||||
const rev = framedHash('batch', sourceMap === undefined ? [script] : [script, sourceMap])
|
||||
const sourceMap = Buffer.from(`${JSON.stringify({ version: 3, file: 'client.js', sections })}\n`)
|
||||
const sourceBytes = Buffer.from(source)
|
||||
const rev = revision ?? framedHash('combo', [sourceBytes, sourceMap])
|
||||
const entries = records.map(record => record.entry.id)
|
||||
const url = comboUrl(entries, rev)
|
||||
const sourceMapUrl = comboUrl(entries, rev, true)
|
||||
return { url, rev, entries, script: comboScript(source, sourceMapUrl), sourceMap, sourceMapUrl }
|
||||
}
|
||||
|
||||
/** Add initial-load scheduling metadata to a combo artifact. */
|
||||
function buildBatch(phase: WebBootBatchPhase, records: readonly WebPluginRecord[]): BatchArtifact {
|
||||
const artifact = buildCombo(records)
|
||||
return {
|
||||
descriptor: {
|
||||
phase,
|
||||
url: `/plugins/_batch/${phase}/${rev}/client.js`,
|
||||
rev,
|
||||
entries: records.map(record => record.entry.id),
|
||||
},
|
||||
script,
|
||||
...(sourceMap === undefined ? {} : { sourceMap }),
|
||||
...artifact,
|
||||
descriptor: { phase, url: artifact.url, rev: artifact.rev, entries: artifact.entries },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,7 +391,7 @@ function buildBatch(phase: WebBootBatchPhase, records: readonly WebPluginRecord[
|
||||
function graphRow(id: string, rev: string, fields: WebBootRowFields): WebBootEntry {
|
||||
return {
|
||||
id,
|
||||
url: `/plugins/${id}/client.js?rev=${rev}`,
|
||||
url: comboUrl([id], rev),
|
||||
rev,
|
||||
...(fields.inject !== undefined ? { inject: fields.inject } : {}),
|
||||
...(fields.immediately ? { immediately: true } : {}),
|
||||
@@ -375,7 +457,8 @@ const PARSER_PRELOAD_IDS = [CLIENT_MODULES_ID] as const
|
||||
* in live-registration mode. The graph global follows before the shell reads
|
||||
* it.
|
||||
* @param graph - the composed entry graph.
|
||||
* @returns head rows in execution order: queue script, preload scripts, graph global.
|
||||
* @returns head rows in execution order: queue script, application preloads,
|
||||
* blocking bootstrap scripts, graph global.
|
||||
*/
|
||||
export function bootInjections(graph: WebBootGraph): IndexInjection[] {
|
||||
const bootstrapId = JSON.stringify(CLIENT_MODULES_ID)
|
||||
@@ -401,14 +484,14 @@ window.__ModuleLoader__={
|
||||
}
|
||||
}
|
||||
})()`
|
||||
const bootstrap = graph.batches.find(batch => batch.phase === 'bootstrap')
|
||||
const application = graph.batches.find(batch => batch.phase === 'application')
|
||||
const bootstrap = graph.batches.filter(batch => batch.phase === 'bootstrap')
|
||||
const application = graph.batches.filter(batch => batch.phase === 'application')
|
||||
const rows: IndexInjection[] = [{ kind: 'script', placement: 'head', text: queue }]
|
||||
if (application !== undefined) {
|
||||
rows.push({ kind: 'script-preload', src: application.url })
|
||||
for (const batch of application) {
|
||||
rows.push({ kind: 'script-preload', src: batch.url })
|
||||
}
|
||||
if (bootstrap !== undefined) {
|
||||
rows.push({ kind: 'script-src', placement: 'head', src: bootstrap.url })
|
||||
for (const batch of bootstrap) {
|
||||
rows.push({ kind: 'script-src', placement: 'head', src: batch.url })
|
||||
}
|
||||
rows.push({ kind: 'global', name: '__DSH_BOOT__', value: graph })
|
||||
return rows
|
||||
@@ -435,6 +518,7 @@ export class ClientModuleRegistry extends Service {
|
||||
private readonly resolvePkgJson: (spec: string) => string
|
||||
private readonly initialRevisionNonce = randomBytes(8).toString('hex')
|
||||
private nextInitialRevision = 0
|
||||
private responses = new Map<string, { body: Buffer; contentType: string }>()
|
||||
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 }>()
|
||||
@@ -539,7 +623,6 @@ export class ClientModuleRegistry extends Service {
|
||||
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()
|
||||
@@ -588,8 +671,12 @@ export class ClientModuleRegistry extends Service {
|
||||
.map(entry => this.table.get(entry.id))
|
||||
.filter((record): record is WebPluginRecord => record !== undefined)
|
||||
const artifacts: BatchArtifact[] = []
|
||||
if (bootstrap.length > 0) artifacts.push(buildBatch('bootstrap', bootstrap))
|
||||
if (application.length > 0) artifacts.push(buildBatch('application', application))
|
||||
for (const records of partitionComboRecords(bootstrap)) {
|
||||
artifacts.push(buildBatch('bootstrap', records))
|
||||
}
|
||||
for (const records of partitionComboRecords(application)) {
|
||||
artifacts.push(buildBatch('application', records))
|
||||
}
|
||||
|
||||
const batchResponses = new Map<string, { body: Buffer; contentType: string }>()
|
||||
for (const artifact of artifacts) {
|
||||
@@ -597,15 +684,26 @@ export class ClientModuleRegistry extends Service {
|
||||
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',
|
||||
})
|
||||
}
|
||||
batchResponses.set(artifact.sourceMapUrl, {
|
||||
body: artifact.sourceMap,
|
||||
contentType: 'application/json; charset=utf-8',
|
||||
})
|
||||
}
|
||||
const responses = new Map(batchResponses)
|
||||
for (const record of this.table.values()) {
|
||||
const artifact = buildCombo([record], record.entry.rev)
|
||||
responses.set(artifact.url, {
|
||||
body: artifact.script,
|
||||
contentType: 'text/javascript; charset=utf-8',
|
||||
})
|
||||
responses.set(artifact.sourceMapUrl, {
|
||||
body: artifact.sourceMap,
|
||||
contentType: 'application/json; charset=utf-8',
|
||||
})
|
||||
}
|
||||
this.previousBatchResponses = this.batchResponses
|
||||
this.batchResponses = batchResponses
|
||||
this.responses = responses
|
||||
const batches = artifacts.map(artifact => artifact.descriptor)
|
||||
return { rev: shortHash(JSON.stringify({ entries, batches })), entries, batches }
|
||||
}
|
||||
@@ -705,7 +803,7 @@ export class ClientModuleRegistry extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
/** Treat a missing, torn, or malformed development map as an unmapped artifact revision. */
|
||||
/** Treat a missing, torn, or malformed development map as an identity-mapped artifact revision. */
|
||||
private readSourceMapSnapshot(clientPath: string): WebPluginRecord['sourceMap'] {
|
||||
try {
|
||||
return sourceMapSnapshot(clientPath)
|
||||
@@ -737,7 +835,6 @@ export class ClientModuleRegistry extends Service {
|
||||
meta,
|
||||
bundle: snapshot.bundle,
|
||||
baseline: snapshot.baseline,
|
||||
individualBundle: individualBundle(snapshot.bundle, rev, snapshot.sourceMap !== undefined),
|
||||
...(snapshot.sourceMap === undefined ? {} : { sourceMap: snapshot.sourceMap }),
|
||||
})
|
||||
return true
|
||||
@@ -779,43 +876,20 @@ export class ClientModuleRegistry extends Service {
|
||||
}
|
||||
/* v8 ignore next -- `?? '/'` arm: node:http always sets url on server requests. */
|
||||
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) {
|
||||
const resourceUrl = `${requestUrl.pathname}${requestUrl.search}`
|
||||
const response = this.responses.get(resourceUrl) ?? this.previousBatchResponses.get(resourceUrl)
|
||||
if (response !== undefined) {
|
||||
res.writeHead(200, {
|
||||
'content-type': batch.contentType,
|
||||
'content-type': response.contentType,
|
||||
'cache-control': IMMUTABLE_CACHE,
|
||||
})
|
||||
res.end(req.method === 'HEAD' ? undefined : batch.body)
|
||||
res.end(req.method === 'HEAD' ? undefined : response.body)
|
||||
return
|
||||
}
|
||||
// The id may contain a scope slash. Anything else under /plugins (including
|
||||
// Anything else under /plugins (including unadvertised combinations and
|
||||
// /plugins/events when the HMR row is absent) is an unknown resource.
|
||||
const prefix = '/plugins/'
|
||||
const mapSuffix = '/client.js.map'
|
||||
const bundleSuffix = '/client.js'
|
||||
const isSourceMap = pathname.startsWith(prefix) && pathname.endsWith(mapSuffix)
|
||||
const suffix = isSourceMap ? mapSuffix : bundleSuffix
|
||||
const id = pathname.startsWith(prefix) && pathname.endsWith(suffix)
|
||||
? pathname.slice(prefix.length, -suffix.length)
|
||||
: 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
|
||||
}
|
||||
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)
|
||||
res.writeHead(404)
|
||||
res.end()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,11 @@ 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 comboUrl = (ids: readonly string[], rev: string): string =>
|
||||
`/plugins/??${ids.map(id => `${id}/client.js`).join(',')}&rev=${rev}`
|
||||
const BOOTSTRAP_URL = comboUrl([MODULES_ID], 'bootstrap')
|
||||
const APPLICATION_URL = comboUrl(['a', 'b'], 'application')
|
||||
const win = globalThis as DshWindow
|
||||
const bootstrapExports = { apply, createClientModuleSystem }
|
||||
|
||||
@@ -24,7 +27,7 @@ afterEach(() => {
|
||||
const row = (id: string, fields: Partial<BootModuleRow> = {}): BootModuleRow =>
|
||||
({
|
||||
id,
|
||||
url: `/plugins/${id}/client.js?rev=0`,
|
||||
url: comboUrl([id], '0'),
|
||||
initialUrl: id === MODULES_ID ? BOOTSTRAP_URL : APPLICATION_URL,
|
||||
rev: '0',
|
||||
inject: [],
|
||||
@@ -83,8 +86,12 @@ function bench(
|
||||
: 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 parsed = new URL(url, 'http://dsh.invalid')
|
||||
const combo = parsed.search.startsWith('??') ? parsed.search.slice(2).split('&', 1)[0] : undefined
|
||||
const singleId = combo?.split(',').length === 1 && combo.endsWith('/client.js')
|
||||
? combo.slice(0, -'/client.js'.length)
|
||||
: undefined
|
||||
for (const id of batchIds ?? (singleId === undefined ? [] : [singleId])) {
|
||||
const factory = bundles[id]
|
||||
if (factory != null) win.__ModuleLoader__?.load({ id, factory })
|
||||
}
|
||||
@@ -374,21 +381,42 @@ describe('boot manifest wire', () => {
|
||||
.toThrow('client-modules: boot manifest batches must be an array')
|
||||
})
|
||||
|
||||
it('rejects malformed and duplicate batch phases', () => {
|
||||
it('rejects malformed 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"')
|
||||
})
|
||||
|
||||
it('rejects duplicate batch URLs', () => {
|
||||
expect(() => parseBootManifest({
|
||||
rev: 'graph',
|
||||
entries: [entry],
|
||||
entries: [
|
||||
{ id: 'a', url: '/a.js', rev: '1' },
|
||||
{ id: 'b', url: '/b.js', rev: '2' },
|
||||
],
|
||||
batches: [
|
||||
{ phase: 'application', url: '/combo.js', rev: '1', entries: ['a'] },
|
||||
{ phase: 'application', url: '/combo.js', rev: '2', entries: ['b'] },
|
||||
],
|
||||
})).toThrow('boot manifest carries duplicate batch URL "/combo.js"')
|
||||
})
|
||||
|
||||
it('allows several batches in one scheduling phase', () => {
|
||||
const manifest = parseBootManifest({
|
||||
rev: 'graph',
|
||||
entries: [
|
||||
{ id: 'a', url: '/a.js', rev: '1' },
|
||||
{ id: 'b', url: '/b.js', rev: '2' },
|
||||
],
|
||||
batches: [
|
||||
{ phase: 'application', url: '/b.js', rev: '1', entries: ['a'] },
|
||||
{ phase: 'application', url: '/c.js', rev: '2', entries: ['a'] },
|
||||
{ phase: 'application', url: '/c.js', rev: '2', entries: ['b'] },
|
||||
],
|
||||
})).toThrow('boot manifest carries duplicate "application" batches')
|
||||
})
|
||||
expect(manifest.modules.map(row => row.initialUrl)).toEqual(['/b.js', '/c.js'])
|
||||
})
|
||||
|
||||
it('requires complete batch fields and non-empty entries', () => {
|
||||
@@ -436,37 +464,37 @@ describe('HMR reset', () => {
|
||||
expect(b.loader.loadCache.has('a')).toBe(false)
|
||||
await b.loader.prefetch('a')
|
||||
const second = await b.loader.import('a', '', {})
|
||||
expect(b.fetched).toEqual([APPLICATION_URL, '/plugins/a/client.js?rev=1'])
|
||||
expect(b.fetched).toEqual([APPLICATION_URL, comboUrl(['a'], '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 () => {
|
||||
it('preserves an absolute combo endpoint when applying the rebuilt revision', async () => {
|
||||
const b = bench([
|
||||
row('a', { url: 'https://plugins.example.test/plugins/a/client.js?rev=0' }),
|
||||
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')
|
||||
expect(b.fetched.at(-1)).toBe('https://plugins.example.test/plugins/??a/client.js&rev=next')
|
||||
})
|
||||
|
||||
it('preserves a protocol-relative individual endpoint when applying the rebuilt revision', async () => {
|
||||
it('preserves a protocol-relative combo endpoint when applying the rebuilt revision', async () => {
|
||||
const b = bench([
|
||||
row('a', { url: '//plugins.example.test/plugins/a/client.js?rev=0' }),
|
||||
row('a', { url: '//plugins.example.test/plugins/??a/client.js&rev=0' }),
|
||||
], { a: () => ({}) })
|
||||
await b.loader.import('a', '', {})
|
||||
b.loader.invalidate('a', 'next')
|
||||
await b.loader.prefetch('a')
|
||||
expect(b.fetched.at(-1)).toBe('//plugins.example.test/plugins/a/client.js?rev=next')
|
||||
expect(b.fetched.at(-1)).toBe('//plugins.example.test/plugins/??a/client.js&rev=next')
|
||||
})
|
||||
|
||||
it('uses the current individual revision when a graph-row invalidation omits an override', async () => {
|
||||
it('uses the current plugin 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'])
|
||||
expect(b.fetched).toEqual([APPLICATION_URL, comboUrl(['a'], '0')])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -16,8 +16,12 @@ 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'
|
||||
|
||||
const comboUrl = (ids: readonly string[], rev: string): string =>
|
||||
`/plugins/??${ids.map(id => `${id}/client.js`).join(',')}&rev=${rev}`
|
||||
const mapUrl = (url: string): string => url.replace(/\/client\.js(?=,|&rev=)/g, '/client.js.map')
|
||||
const BOOTSTRAP_URL = comboUrl([MODULES_ID], 'boot')
|
||||
const APPLICATION_URL = comboUrl([UI_RENDERER_ID], 'app')
|
||||
|
||||
let root: string | undefined
|
||||
|
||||
@@ -125,8 +129,8 @@ function injectedFacade(graph: WebBootGraph): { html: string; target: ClientModu
|
||||
const bootGraph = (): WebBootGraph => ({
|
||||
rev: 'graph',
|
||||
entries: [
|
||||
{ id: MODULES_ID, url: '/plugins/modules.js?rev=m', rev: 'm' },
|
||||
{ id: UI_RENDERER_ID, url: '/plugins/ui-renderer.js?rev=r', rev: 'r' },
|
||||
{ id: MODULES_ID, url: comboUrl([MODULES_ID], 'm'), rev: 'm' },
|
||||
{ id: UI_RENDERER_ID, url: comboUrl([UI_RENDERER_ID], 'r'), rev: 'r' },
|
||||
],
|
||||
batches: [
|
||||
{
|
||||
@@ -150,9 +154,9 @@ describe('HTML bootstrap facade', () => {
|
||||
const { html, target } = injectedFacade(graph)
|
||||
const facadeAt = html.indexOf('window.__ModuleLoader__=')
|
||||
const applicationAt = html.indexOf(
|
||||
`<link rel="preload" as="script" href="${APPLICATION_URL}">`,
|
||||
`<link rel="preload" as="script" href="${APPLICATION_URL.replaceAll('&', '&')}">`,
|
||||
)
|
||||
const bootstrapAt = html.indexOf(`<script src="${BOOTSTRAP_URL}"></script>`)
|
||||
const bootstrapAt = html.indexOf(`<script src="${BOOTSTRAP_URL.replaceAll('&', '&')}"></script>`)
|
||||
const graphAt = html.indexOf('globalThis["__DSH_BOOT__"] = ')
|
||||
const entryAt = html.indexOf('<script type="module" src="/index.js"></script>')
|
||||
expect([facadeAt, applicationAt, bootstrapAt, graphAt, entryAt]).toEqual([...new Set([
|
||||
@@ -178,6 +182,16 @@ describe('HTML bootstrap facade', () => {
|
||||
.toThrow('create called after module-system boot')
|
||||
})
|
||||
|
||||
it('preloads every application combo', () => {
|
||||
const graph = bootGraph()
|
||||
const secondId = '@fixture/second-application-combo'
|
||||
const secondUrl = comboUrl([secondId], 'app-2')
|
||||
graph.entries.push({ id: secondId, url: comboUrl([secondId], 'row-2'), rev: 'row-2' })
|
||||
graph.batches.push({ phase: 'application', url: secondUrl, rev: 'app-2', entries: [secondId] })
|
||||
expect(bootInjections(graph).flatMap(row => row.kind === 'script-preload' ? [row.src] : []))
|
||||
.toEqual([APPLICATION_URL, secondUrl])
|
||||
})
|
||||
|
||||
it('rejects a page that did not preload the modules bundle', () => {
|
||||
const graph = bootGraph()
|
||||
const { target } = injectedFacade(graph)
|
||||
@@ -258,7 +272,7 @@ describe('client bundle activation', () => {
|
||||
expect(String(thrown)).not.toContain('pnpm run build')
|
||||
})
|
||||
|
||||
it('omits a torn or malformed source map without blocking composition', async () => {
|
||||
it('falls back to a generated-file map when an authored map is malformed', async () => {
|
||||
const packageName = '@fixture/malformed-source-map'
|
||||
const clientPath = writePackage(packageName)
|
||||
mkdirSync(dirname(clientPath), { recursive: true })
|
||||
@@ -267,13 +281,60 @@ describe('client bundle activation', () => {
|
||||
const torn = constructWithRoute([packageName])
|
||||
const tornRow = torn.service.graph().entries[0]!
|
||||
expect((await routeRequest(torn.route, tornRow.url)).body.toString('utf8'))
|
||||
.not.toContain('sourceMappingURL')
|
||||
expect((await routeRequest(torn.route, `${torn.service.graph().batches[0]!.url}.map`)).status).toBe(404)
|
||||
.toContain(`sourceMappingURL=${mapUrl(tornRow.url)}`)
|
||||
const fallback = await routeRequest(torn.route, mapUrl(torn.service.graph().batches[0]!.url))
|
||||
expect(JSON.parse(fallback.body.toString('utf8'))).toMatchObject({
|
||||
sections: [{ map: { sources: [`/plugins/${packageName}/client.js`] } }],
|
||||
})
|
||||
|
||||
writeFileSync(`${clientPath}.map`, '{"version":3,"sources":[null]}\n')
|
||||
expect(() => construct([packageName])).not.toThrow()
|
||||
})
|
||||
|
||||
it('maps packed combo sections back to each generated client bundle', async () => {
|
||||
const names = ['@fixture/generated-first', '@fixture/generated-second']
|
||||
for (const [index, packageName] of names.entries()) {
|
||||
const clientPath = writePackage(packageName)
|
||||
mkdirSync(dirname(clientPath), { recursive: true })
|
||||
writeFileSync(
|
||||
clientPath,
|
||||
`window.generation = ${String(index)}\n//# sourceURL=packages/client/generated-${String(index)}/lib/client.js`,
|
||||
)
|
||||
}
|
||||
|
||||
const { service, route } = constructWithRoute(names)
|
||||
const batch = service.graph().batches[0]!
|
||||
const script = (await routeRequest(route, batch.url)).body.toString('utf8')
|
||||
expect(script).not.toContain('//# sourceURL=')
|
||||
expect(script).toContain(`//# sourceMappingURL=${mapUrl(batch.url)}`)
|
||||
const payload = JSON.parse((await routeRequest(route, mapUrl(batch.url))).body.toString('utf8')) as {
|
||||
sections: { map: { mappings: string; sources: string[]; sourcesContent: string[] } }[]
|
||||
}
|
||||
expect(payload.sections.map(section => section.map)).toEqual([
|
||||
{
|
||||
version: 3,
|
||||
names: [],
|
||||
mappings: 'AAAA',
|
||||
sources: ['/packages/client/generated-0/lib/client.js'],
|
||||
sourcesContent: ['window.generation = 0\n'],
|
||||
},
|
||||
{
|
||||
version: 3,
|
||||
names: [],
|
||||
mappings: 'AAAA',
|
||||
sources: ['/packages/client/generated-1/lib/client.js'],
|
||||
sourcesContent: ['window.generation = 1\n'],
|
||||
},
|
||||
])
|
||||
const consumer = new SourceMap(payload as unknown as ConstructorParameters<typeof SourceMap>[0])
|
||||
expect(consumer.findEntry(0, 0)).toMatchObject({
|
||||
originalSource: '/packages/client/generated-0/lib/client.js',
|
||||
})
|
||||
expect(consumer.findEntry(2, 0)).toMatchObject({
|
||||
originalSource: '/packages/client/generated-1/lib/client.js',
|
||||
})
|
||||
})
|
||||
|
||||
it('retains one prior immutable batch generation across rebuild recomposition', async () => {
|
||||
const packageName = '@fixture/batch-rebuild-race'
|
||||
const clientPath = writePackage(packageName)
|
||||
@@ -323,6 +384,39 @@ describe('client bundle activation', () => {
|
||||
expect(service.artifactBaseline('@fixture/unknown')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('splits startup combos before the map-form URL exceeds 3 KiB', async () => {
|
||||
const packageNames = Array.from({ length: 48 }, (_, index) => (
|
||||
`@fixture/combo-url-${String(index).padStart(3, '0')}-${'x'.repeat(40)}`
|
||||
))
|
||||
const sourceMap = JSON.stringify({
|
||||
version: 3,
|
||||
names: [],
|
||||
mappings: 'AAAA',
|
||||
sources: ['src/index.ts'],
|
||||
})
|
||||
for (const packageName of packageNames) {
|
||||
const clientPath = writePackage(packageName)
|
||||
mkdirSync(dirname(clientPath), { recursive: true })
|
||||
writeFileSync(clientPath, 'module.exports = {}\n')
|
||||
writeFileSync(`${clientPath}.map`, sourceMap)
|
||||
}
|
||||
|
||||
const { service, route } = constructWithRoute(packageNames)
|
||||
const batches = service.graph().batches.filter(batch => batch.phase === 'application')
|
||||
expect(batches.length).toBeGreaterThan(1)
|
||||
expect(batches.flatMap(batch => batch.entries)).toEqual(packageNames)
|
||||
for (const batch of batches) {
|
||||
expect(Buffer.byteLength(batch.url)).toBeLessThanOrEqual(3 * 1024)
|
||||
expect(Buffer.byteLength(mapUrl(batch.url))).toBeLessThanOrEqual(3 * 1024)
|
||||
expect((await routeRequest(route, batch.url)).status).toBe(200)
|
||||
expect((await routeRequest(route, mapUrl(batch.url))).status).toBe(200)
|
||||
}
|
||||
for (let index = 0; index < batches.length - 1; index += 1) {
|
||||
const entries = [...batches[index]!.entries, batches[index + 1]!.entries[0]!]
|
||||
expect(Buffer.byteLength(mapUrl(comboUrl(entries, '0'.repeat(12))))).toBeGreaterThan(3 * 1024)
|
||||
}
|
||||
})
|
||||
|
||||
it('serves the source map beside a registered client bundle', async () => {
|
||||
const packageName = '@fixture/source-map'
|
||||
const clientPath = writePackage(packageName)
|
||||
@@ -332,35 +426,44 @@ describe('client bundle activation', () => {
|
||||
writeFileSync(`${clientPath}.map`, map)
|
||||
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({
|
||||
const singleScript = await routeRequest(route, row.url)
|
||||
expect(singleScript.body.toString('utf8')).toContain(`sourceMappingURL=${mapUrl(row.url)}`)
|
||||
const singleMap = await routeRequest(route, mapUrl(row.url))
|
||||
expect(singleMap.status).toBe(200)
|
||||
expect(singleMap.headers).toEqual({
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
'cache-control': 'public, max-age=31536000, immutable',
|
||||
})
|
||||
expect(individual.body.toString('utf8')).toBe(map)
|
||||
expect(JSON.parse(singleMap.body.toString('utf8'))).toMatchObject({
|
||||
version: 3,
|
||||
file: 'client.js',
|
||||
sections: [{
|
||||
offset: { line: 0, column: 0 },
|
||||
map: {
|
||||
...(JSON.parse(map) as Record<string, unknown>),
|
||||
sources: ['/packages/client/demo/src/index.tsx', 'https://cdn.example.test/library.js'],
|
||||
},
|
||||
}],
|
||||
})
|
||||
|
||||
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(batchScript.body.toString('utf8')).toContain(`//# sourceMappingURL=${mapUrl(batch.url)}`)
|
||||
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 batchMap = await routeRequest(route, mapUrl(batch.url))
|
||||
const parsedBatchMap = JSON.parse(batchMap.body.toString('utf8')) as unknown
|
||||
const parsedIndividualMap = JSON.parse(map) as Record<string, unknown>
|
||||
const parsedPluginMap = JSON.parse(map) as Record<string, unknown>
|
||||
expect(parsedBatchMap).toMatchObject({
|
||||
version: 3,
|
||||
file: 'client.js',
|
||||
sections: [{
|
||||
offset: { line: 0, column: 0 },
|
||||
map: {
|
||||
...parsedIndividualMap,
|
||||
...parsedPluginMap,
|
||||
sources: ['/packages/client/demo/src/index.tsx', 'https://cdn.example.test/library.js'],
|
||||
},
|
||||
}],
|
||||
@@ -370,8 +473,12 @@ describe('client bundle activation', () => {
|
||||
writeFileSync(`${clientPath}.map`, '{"version":3,"names":[],"mappings":"AAAA","sources":["src/changed.tsx"]}\n')
|
||||
const nextRev = service.rebuilt(packageName)
|
||||
expect(nextRev).not.toBe(row.rev)
|
||||
const nextMap = await routeRequest(route, `/plugins/${packageName}/client.js.map?rev=${String(nextRev)}`)
|
||||
expect(JSON.parse(nextMap.body.toString('utf8'))).toMatchObject({ sources: ['src/changed.tsx'] })
|
||||
const nextRow = service.graph().entries[0]!
|
||||
expect(nextRow.rev).toBe(nextRev)
|
||||
const nextMap = await routeRequest(route, mapUrl(nextRow.url))
|
||||
expect(JSON.parse(nextMap.body.toString('utf8'))).toMatchObject({
|
||||
sections: [{ map: { sources: ['/plugins/@fixture/source-map/src/changed.tsx'] } }],
|
||||
})
|
||||
})
|
||||
|
||||
it('applies sourceRoot before relocating absolute-looking section sources', async () => {
|
||||
@@ -387,7 +494,7 @@ describe('client bundle activation', () => {
|
||||
sources: ['/absolute.ts'],
|
||||
}))
|
||||
const { service, route } = constructWithRoute([packageName])
|
||||
const response = await routeRequest(route, `${service.graph().batches[0]!.url}.map`)
|
||||
const response = await routeRequest(route, mapUrl(service.graph().batches[0]!.url))
|
||||
const map = JSON.parse(response.body.toString('utf8')) as {
|
||||
sections: { map: { sourceRoot?: string; sources: string[] } }[]
|
||||
}
|
||||
@@ -417,7 +524,7 @@ describe('client bundle activation', () => {
|
||||
}))
|
||||
}
|
||||
const { service, route } = constructWithRoute([firstName, secondName])
|
||||
const response = await routeRequest(route, `${service.graph().batches[0]!.url}.map`)
|
||||
const response = await routeRequest(route, mapUrl(service.graph().batches[0]!.url))
|
||||
const payload = JSON.parse(response.body.toString('utf8')) as ConstructorParameters<typeof SourceMap>[0]
|
||||
const sections = (payload as unknown as {
|
||||
sections: { offset: { line: number; column: number } }[]
|
||||
@@ -431,7 +538,7 @@ describe('client bundle activation', () => {
|
||||
expect(consumer.findEntry(3, 0)).toMatchObject({ originalSource: '/packages/demo/second.ts' })
|
||||
})
|
||||
|
||||
it('keeps a later source-map section usable when an earlier bundle has no map', async () => {
|
||||
it('combines a generated-file fallback with a later authored map', async () => {
|
||||
const unmappedName = '@fixture/unmapped-first'
|
||||
const mappedName = '@fixture/mapped-second'
|
||||
const unmappedPath = writePackage(unmappedName)
|
||||
@@ -449,9 +556,12 @@ describe('client bundle activation', () => {
|
||||
}))
|
||||
|
||||
const { service, route } = constructWithRoute([unmappedName, mappedName])
|
||||
const response = await routeRequest(route, `${service.graph().batches[0]!.url}.map`)
|
||||
const response = await routeRequest(route, mapUrl(service.graph().batches[0]!.url))
|
||||
const payload = JSON.parse(response.body.toString('utf8')) as ConstructorParameters<typeof SourceMap>[0]
|
||||
const consumer = new SourceMap(payload)
|
||||
expect(consumer.findEntry(0, 0)).toMatchObject({
|
||||
originalSource: `/plugins/${unmappedName}/client.js`,
|
||||
})
|
||||
expect(consumer.findEntry(2, 0)).toMatchObject({ originalSource: '/packages/demo/mapped.ts' })
|
||||
})
|
||||
})
|
||||
@@ -462,7 +572,7 @@ describe('shared module declarations', () => {
|
||||
writeBuiltPackage(packageName, { external: ['react'] })
|
||||
expect(construct([packageName]).graph().entries).toEqual([{
|
||||
id: packageName,
|
||||
url: expect.stringContaining(`/plugins/${packageName}/client.js?rev=`) as unknown as string,
|
||||
url: expect.stringContaining(`/plugins/??${packageName}/client.js&rev=`) as unknown as string,
|
||||
rev: expect.any(String) as unknown as string,
|
||||
external: ['react'],
|
||||
}])
|
||||
@@ -485,7 +595,7 @@ describe('shared module declarations', () => {
|
||||
|
||||
describe('module graph order', () => {
|
||||
const entry = (id: string, fields: Partial<WebBootEntry> = {}): WebBootEntry =>
|
||||
({ id, url: `/plugins/${id}/client.js?rev=0`, rev: '0', ...fields })
|
||||
({ id, url: comboUrl([id], '0'), rev: '0', ...fields })
|
||||
const ids = (entries: readonly WebBootEntry[]): string[] => entries.map(row => row.id)
|
||||
|
||||
it('places every requested package row before its consumers along a chain', () => {
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/experimental/webworker-packer/README.md
|
||||
README.md: 39d084bc631db387a3b6e526a3a374bc9f766577
|
||||
README.zh.md: 11aa04f3db36c09525bc4d4945f77e278602cc0f
|
||||
README.md: 35ed9c92eed64b6ae1207c2a0c89901554027d36
|
||||
README.zh.md: 12edcea5e02c1f5f4b31d695e5693fdc4958381a
|
||||
|
||||
@@ -7,7 +7,7 @@ The VFS image packer: turns one composed profile into the gzip-compressed base t
|
||||
The pack is a three-layer standard stack:
|
||||
|
||||
1. **Roster** — the composed profile's plugin rows (standard YAML parse under Include's dialect, `!!js` intact), plus the rows of every config tree the CLI declares in its `package.json` `dsh.configTrees` (agent presets), materialized as a Node-style dependency closure. External peer edges never bind the worker; workspace peers stay on the chain.
|
||||
2. **Publish view** — each workspace package contributes the slice npm would publish (`files` through picomatch) minus the rule tables in `src/rules.ts` (no sources, no workspace `dist/`; external packages keep their trees minus the same exclude globs).
|
||||
2. **Publish view** — each workspace or vendored package contributes its built npm slice (`files` through picomatch) without source or workspace `dist/`. External packages retain published JavaScript under both `src/` and `dist/` because their `main` or `exports` may point there; only generic test, map, declaration, and archive exclusions apply.
|
||||
3. **Reachability sweep** — the runtime loader's own resolution walks from every workspace export face plus the worker assembly's seeds (`IMAGE_ENTRY_SEEDS`), lowering each reached module to the wrapper contract at pack time. Page assets (`lib/client.js` behind `./client` exports) ship verbatim; an unresolvable request from our own code fails the pack, third-party ones are tolerated to fail loud at require time.
|
||||
|
||||
`repository.ts` owns the repo-shaped inputs (workspace scan of `vendor/`, `packages/`, `native/landlock-run/packages/`, and `apps/`; profile composition through the real CLI dump path); `pack.ts` owns none of them, so the same library packs a different tree by being called differently. The native scan makes the Landlock entry package an ordinary published-view dependency while its executable remains a Worker platform implementation. The CLI is `dsh-pack-vfs-image --out <file> [--profile web]`; `apps/web`'s `build:preview` runs it after the preview shell build.
|
||||
|
||||
@@ -7,7 +7,7 @@ VFS 镜像打包器:把一份合成 profile 变成浏览器 worker 挂载为
|
||||
打包是三层标准栈:
|
||||
|
||||
1. **Roster**——合成 profile 的插件行(标准 YAML 解析、Include 方言、`!!js` 原样保留),加上 CLI 在 `package.json` `dsh.configTrees` 里声明的每棵配置树(agent presets)的行,按 Node 式依赖闭包物化。外部包的 peer 边不追,workspace peer 保留在链上。
|
||||
2. **发布视图**——每个 workspace 包贡献 npm 会发布的切片(`files` 走 picomatch),再减去 `src/rules.ts` 的规则表(无源码、无 workspace `dist/`;外部包保留整棵减同一套 exclude glob)。
|
||||
2. **发布视图**——每个 workspace 或 vendored 包贡献其构建后的 npm 切片(`files` 走 picomatch),不带源码和 workspace `dist/`。外部包的 `main` 或 `exports` 可能指向 `src/` 或 `dist/`,因此两处发布 JavaScript 都会保留,只应用通用的测试、map、声明与归档排除规则。
|
||||
3. **可达性 sweep**——用运行时加载器自己的解析,从全部 workspace 导出面加 worker 装配种子(`IMAGE_ENTRY_SEEDS`)出发,pack 时把每个可达模块降低到包装契约。页面资产(`./client` 导出背后的 `lib/client.js`)原样直发;自家代码的不可解析请求打包即失败,第三方的容忍到 require 时 fail loud。
|
||||
|
||||
`repository.ts` 拥有仓库形态输入(`vendor/`、`packages/`、`native/landlock-run/packages/` 与 `apps/` 的 workspace 扫描;经真 CLI dump 路径合成 profile);`pack.ts` 一概不拥有,同一库换参即可打另一棵树。Native 扫描使 Landlock 入口包成为普通发布视图依赖,其可执行文件仍由 Worker 平台实现。CLI 为 `dsh-pack-vfs-image --out <file> [--profile web]`;`apps/web` 的 `build:preview` 在预览壳构建后运行它。
|
||||
|
||||
@@ -8,13 +8,12 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* Paths dropped from every collected tree. Source and test trees never
|
||||
* resolve at runtime (the artifact plane ships `lib/`), and sourcemaps,
|
||||
* declarations, and archives never resolve either while dominating the byte
|
||||
* count.
|
||||
* Paths dropped from every collected tree. Test trees, sourcemaps,
|
||||
* declarations, and archives never resolve at runtime while dominating the
|
||||
* byte count. Third-party `src/` directories remain eligible because package
|
||||
* entrypoints may resolve to JavaScript there.
|
||||
*/
|
||||
export const EXCLUDE: readonly string[] = [
|
||||
'src/**',
|
||||
'tests/**',
|
||||
'test/**',
|
||||
'__tests__/**',
|
||||
@@ -30,11 +29,13 @@ export const EXCLUDE: readonly string[] = [
|
||||
]
|
||||
|
||||
/**
|
||||
* Additional paths dropped from workspace packages only. A workspace `dist/`
|
||||
* is a page-asset tree the static deployment serves itself; external packages
|
||||
* legitimately ship runtime code under `dist/`.
|
||||
* Additional paths dropped from workspace and vendored packages only. Their
|
||||
* runtime plane is built `lib/`; a workspace `dist/` is a page-asset tree the
|
||||
* static deployment serves itself. External packages may place runtime code
|
||||
* under either directory.
|
||||
*/
|
||||
export const EXCLUDE_WORKSPACE: readonly string[] = [
|
||||
'src/**',
|
||||
'dist/**',
|
||||
]
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
|
||||
const SUBJECT = '@deepseek-ai/dsh-timeout'
|
||||
const LANDLOCK = '@deepseek-ai/node-addon-landlock-run'
|
||||
const PLUGIN_INVENTORY = '@deepseek-ai/dsh-plugin-package-inventory-deepseek'
|
||||
const WEB_SERVER = '@deepseek-ai/dsh-host-webserver'
|
||||
|
||||
const workspaces = indexWorkspacePackages(repoRoot)
|
||||
|
||||
@@ -103,6 +104,15 @@ const packedPluginInventory = (): ReturnType<typeof packVfsImage> => pluginInven
|
||||
entries: [],
|
||||
})
|
||||
|
||||
let webServerMemo: ReturnType<typeof packVfsImage> | undefined
|
||||
const packedWebServer = (): ReturnType<typeof packVfsImage> => webServerMemo ??= packVfsImage({
|
||||
config: `- id: subject\n name: '${WEB_SERVER}'\n`,
|
||||
profile: 'webserver-dependency-check',
|
||||
workspaces,
|
||||
resolveFrom: repoRoot,
|
||||
entries: [],
|
||||
})
|
||||
|
||||
/** The image's archive, inflated once: mounting reads the tar, not the gzip member. */
|
||||
let archiveMemo: Uint8Array | undefined
|
||||
const archive = async (): Promise<Uint8Array> =>
|
||||
@@ -188,6 +198,25 @@ const archive = async (): Promise<Uint8Array> =>
|
||||
expect(loader.usage().modules).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('keeps third-party runtime JavaScript published under src', async () => {
|
||||
const result = packedWebServer()
|
||||
expect(result.missing).toEqual([])
|
||||
expect(Object.hasOwn(result.files, 'node_modules/debug/src/index.js')).toBe(true)
|
||||
expect(Object.hasOwn(result.files, 'node_modules/ms/index.js')).toBe(true)
|
||||
|
||||
const vfs = loadVfsImage(await inflateImage(result.image, 'the packed webserver'), DEFAULT_ROOT)
|
||||
const loader = new WorkerModuleLoader({
|
||||
vfs,
|
||||
root: DEFAULT_ROOT,
|
||||
staticModules: createNodeBuiltins(),
|
||||
staticModulePrefixes: REPLACED_PREFIXES,
|
||||
})
|
||||
setActiveVfs(vfs)
|
||||
setActiveModuleLoader(loader)
|
||||
const webserver = loader.requireFrom(`${DEFAULT_ROOT}/workspace`)(WEB_SERVER) as { WebServer?: unknown }
|
||||
expect(typeof webserver.WebServer).toBe('function')
|
||||
})
|
||||
|
||||
it('runs the unchanged Landlock entry package over the Worker platform executable', async () => {
|
||||
const result = packedLandlock()
|
||||
expect(workspaces.has(LANDLOCK)).toBe(true)
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/experimental/webworker-runtime/README.md
|
||||
README.md: f8789f8e82005c15cd38cbe77832e486d4a44b9b
|
||||
README.zh.md: e89ae7fd1e6b5d9ad9aa68edecfd92a81dcaf9b9
|
||||
README.md: 0c445bcd4082a1ff10a6b91218086dfb9c95874c
|
||||
README.zh.md: 6acde51a0aae3483a6e0f1bc80c98d51e9fd47c7
|
||||
|
||||
@@ -9,7 +9,7 @@ Three artifacts from one tsdown pipeline:
|
||||
- **`lib/index.js` (assembly library)** — `createWorkerHost`/`startWorkerHost` mount the base image and any ordered data overlays (`storage/`), install the module loader (`module-system/`) and the `process` shim, boot the tree through the image's own `dsh-app-boot`, and hand the tunnel its serving seams. Overlays may replace files only under `home/` and `workspace/`; they cannot replace the base manifest, configuration, or modules. The image layout contract (`image-layout.ts`: virtual root, config/manifest paths, empty directories, the `lowered` wrapper-contract gate) is shared with the packer. Boot patches force the deployment-shaped rows: frontend serving off, JSONL session logs on the plaintext path, preset roots onto the image's `config/agent-presets`.
|
||||
- **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. `node:module` supplies `createRequire().resolve` and `.resolve.paths()` over the image package root, so unchanged packages can discover manifests without evaluating their modules. VFS mutations drive `node:fs` callback, polling, and promise watchers; open descriptors retain file identity and access mode across rename, replacement, and unlink; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount ([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)).
|
||||
- **`src/shell/` (the worker's own process layer)** — a browser worker cannot fork, so `node:child_process` is not a stub but an implementation: `spawn` starts the command in its own Web Worker — this same bundle, told by its first frame to be a shell process — and reports it through the `ChildProcess` surface the subprocess service consumes. The command runs off the host's thread, `SIGKILL` terminates it whatever it is doing, and it reaches the VFS only by message (the host serves those frames). Worker platform executables preserve native-package protocols such as Landlock without replacing their JavaScript packages or coupling their implementations to `node:child_process`; ordinary commands use the package's evaluator and coreutils command table. The grammar is `@yarnpkg/parsers`' `parseShell`, while `execSync`/`fork` still refuse because they need a real process.
|
||||
- **`lib/client.js` (page half)** — startup has two independent stages. `chooseWorkerHostSource({ image?, fixtureManifest? })` optionally owns the boot barrier and fixture manifest: without `preview-fixture` it waits at the source chooser, while a valid query selects directly; either path returns ordered overlays. `connectWorkerHost(worker, { image?, overlays? })` remains the public base-runtime connector; callers that skip the chooser get an empty overlay list. `apps/web` invokes both and supplies its statically bundled Worker. The opening `init` frame carries the base and ordered overlay URLs, the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. Script preload rows are advisory and skipped because `/plugins` resources resolve only through the tunnel; `loadBundle` performs the actual fetch and execution on first demand. The tunnel also exposes fetch-shaped transport and the API client.
|
||||
- **`lib/client.js` (page half)** — startup has two independent stages. `chooseWorkerHostSource({ image?, fixtureManifest? })` optionally owns the boot barrier and fixture manifest: without `preview-fixture` it waits at the source chooser, while a valid query selects directly; either path returns ordered overlays. `connectWorkerHost(worker, { image?, overlays? })` remains the public base-runtime connector; callers that skip the chooser get an empty overlay list. `apps/web` invokes both and supplies its statically bundled Worker. The opening `init` frame carries the base and ordered overlay URLs, the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. Script preload rows are advisory and skipped because `/plugins` resources resolve only through the tunnel; `loadBundle` fetches each combo on first demand, embeds its tunnel-only source map as a Base64 data URL, and executes the script as a Blob. The tunnel also exposes fetch-shaped transport and the API client.
|
||||
|
||||
Acceptance lives in `apps/web/tests/preview-boot.e2e.ts`, which serves the real built pages and drives the pre-boot chooser plus Worker activation in headless Chromium. The empty selection exercises first-run startup. The `vfs-example` overlay supplies ordinary workspace files and plaintext persistence artifacts for cold Workspace/Session discovery, tool presentation, subagent navigation, and history paging without a model request. The chooser reserves WebFS as a separate user-authorized source; that provider does not read the built-in fixture.
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
- **`lib/index.js`(装配库)**——`createWorkerHost`/`startWorkerHost` 挂载基础镜像和按序排列的数据 overlays(`storage/`)、安装模块加载器(`module-system/`)与 `process` shim、经镜像自带的 `dsh-app-boot` 启动插件树,并把服务缝隙交给隧道。Overlay 只能替换 `home/` 与 `workspace/` 下的文件,不能替换基础 manifest、配置或模块。镜像布局契约(`image-layout.ts`:虚拟根、config/manifest 路径、空目录、`lowered` 包装契约门)与 packer 共享。boot patch 强制部署形态行:关前端静态服务、JSONL 会话日志走明文、preset 根指向镜像内 `config/agent-presets`。
|
||||
- **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。`node:module` 在镜像 package 根之上提供 `createRequire().resolve` 与 `.resolve.paths()`,使未修改的包无需执行目标模块即可发现 manifest。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;打开的 descriptor 在 rename、replacement 和 unlink 后仍保留文件身份与访问模式;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。
|
||||
- **`src/shell/`(worker 自己的进程层)**——浏览器 worker 无法 fork,所以 `node:child_process` 不是 stub 而是实现:`spawn` 把命令放进它自己的 Web Worker——就是这同一个束,由首帧告诉它「你是 shell 进程」——并以 subprocess 服务消费的 `ChildProcess` 面报告结果。命令不占宿主线程,`SIGKILL` 不管它在干什么都能终止它,而它只能靠消息触达 VFS(由宿主应答这些帧)。Worker 平台 executable 在不替换 JavaScript 包、也不把具体实现耦合进 `node:child_process` 的情况下保持 Landlock 等 native 包协议;普通命令使用本包的求值器与 coreutils 命令表。语法来自 `@yarnpkg/parsers` 的 `parseShell`,而 `execSync`/`fork` 依然拒绝,因为它们需要真进程。
|
||||
- **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URL,boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。脚本 preload 行只是提示,因此会被跳过:`/plugins` 资源只能经 tunnel 解析,`loadBundle` 会在首次需要时完成实际获取与执行。Tunnel 还暴露 fetch 形传输与 API 客户端。
|
||||
- **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URL,boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。脚本 preload 行只是提示,因此会被跳过:`/plugins` 资源只能经 tunnel 解析,`loadBundle` 会在首次需要时获取 combo、把仅 tunnel 可达的 sourcemap 内嵌为 Base64 data URL,再以 Blob 执行脚本。Tunnel 还暴露 fetch 形传输与 API 客户端。
|
||||
|
||||
验收在 `apps/web/tests/preview-boot.e2e.ts`:静态服务真实构建页面,在 headless Chromium 里驱动 pre-boot 选择面板与 Worker 激活。空白选择验证首次启动;`vfs-example` overlay 提供普通 workspace 文件与明文 persistence 产物,无需模型请求即可验证 Workspace/Session 冷发现、工具呈现、subagent 导航和历史分页。选择面板为 WebFS 保留独立的用户授权来源;该 provider 不读取内置 fixture。
|
||||
|
||||
|
||||
@@ -90,6 +90,34 @@ class LogicalStreamInbox {
|
||||
const REFUSAL_STATUS = 500
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const SOURCE_MAP_TRAILER = /\/\/# sourceMappingURL=([^\r\n]+)\s*$/
|
||||
const BASE64_CHUNK_BYTES = 32 * 1024
|
||||
|
||||
/** Encode UTF-8 text for an inline data URL without a call-stack-sized spread. */
|
||||
function base64(value: string): string {
|
||||
const bytes = encoder.encode(value)
|
||||
let binary = ''
|
||||
for (let offset = 0; offset < bytes.length; offset += BASE64_CHUNK_BYTES) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, offset + BASE64_CHUNK_BYTES))
|
||||
}
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
/** Replace a tunnel-only map reference with a self-contained Base64 data URL. */
|
||||
async function localizeSourceMap(source: string, bundleUrl: string, fetch: TunnelFetch): Promise<string> {
|
||||
const match = SOURCE_MAP_TRAILER.exec(source)
|
||||
if (match?.[1] === undefined) return source
|
||||
try {
|
||||
const response = await fetch(new URL(match[1], new URL(bundleUrl, globalThis.location.origin)))
|
||||
if (!response.ok) return source.replace(SOURCE_MAP_TRAILER, '')
|
||||
const dataUrl = `data:application/json;charset=utf-8;base64,${base64(await response.text())}`
|
||||
return source.replace(SOURCE_MAP_TRAILER, `//# sourceMappingURL=${dataUrl}`)
|
||||
} catch {
|
||||
// A source map is diagnostic-only; its transport failure must not prevent
|
||||
// the plugin factory from registering.
|
||||
return source.replace(SOURCE_MAP_TRAILER, '')
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize a RequestInit body to a transferable ArrayBuffer. */
|
||||
function toBodyBuffer(body: RequestInit['body']): ArrayBuffer | undefined {
|
||||
@@ -262,14 +290,14 @@ export class WorkerTunnel {
|
||||
* The image packs each bundle with a trailing `sourceURL` naming its image
|
||||
* path, so the blob shows under that name in the debugger instead of as an
|
||||
* anonymous blob entry.
|
||||
* @param url - graph row url (`/plugins/<id>/client.js?rev=...`).
|
||||
* @param url - Graph combo URL (`/plugins/??<id>/client.js&rev=...`).
|
||||
*/
|
||||
async loadBundle(url: string): Promise<void> {
|
||||
const response = await this.fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`web-preview tunnel: bundle ${url} failed with HTTP ${String(response.status)}`)
|
||||
}
|
||||
const source = await response.text()
|
||||
const source = await localizeSourceMap(await response.text(), url, this.fetch)
|
||||
const blob = URL.createObjectURL(new Blob([source], { type: 'text/javascript' }))
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
|
||||
@@ -49,6 +49,8 @@ export const MODULE_PROXIES: Record<string, string> = {
|
||||
'node:events': './node/builtin_modules/implemented/events.ts',
|
||||
'node:timers/promises': './node/builtin_modules/implemented/timers/promises.ts',
|
||||
'node:perf_hooks': './node/builtin_modules/implemented/perf_hooks.ts',
|
||||
'node:tty': './node/builtin_modules/implemented/tty.ts',
|
||||
'tty': './node/builtin_modules/implemented/tty.ts',
|
||||
// Real zstd codec: session-log appends compress on every write.
|
||||
'node:zlib': './node/builtin_modules/implemented/zlib.ts',
|
||||
// The worker's own process layer: `bash -c` and the command table run against
|
||||
|
||||
+9
-3
@@ -115,6 +115,12 @@ class FakeServer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor marker read by middleware during feature detection. Tunnel
|
||||
* responses are synthesized objects and are never instances of this class.
|
||||
*/
|
||||
export class ServerResponse {}
|
||||
|
||||
/**
|
||||
* Create the fake server and retain its request listener for the tunnel.
|
||||
* @param listener - the request listener the webserver installs.
|
||||
@@ -172,8 +178,8 @@ export const __esModule = true
|
||||
* `net.Server` carrying sockets and a Node `RequestListener`, while this one binds
|
||||
* nothing and captures the synthesized-request listener the tunnel feeds.
|
||||
*/
|
||||
type NodeFace = Partial<Omit<typeof import('node:http'), 'Server' | 'createServer'>>
|
||||
& Record<'Server' | 'createServer', unknown>
|
||||
type NodeFace = Partial<Omit<typeof import('node:http'), 'Server' | 'ServerResponse' | 'createServer'>>
|
||||
& Record<'Server' | 'ServerResponse' | 'createServer', unknown>
|
||||
|
||||
/** CommonJS default export: the members `require()` hands a caller of this module. */
|
||||
export default { createServer, request, get, STATUS_CODES, Server: FakeServer } satisfies NodeFace
|
||||
export default { createServer, request, get, STATUS_CODES, Server: FakeServer, ServerResponse } satisfies NodeFace
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ export function createRequire(base: string | URL): NodeRequire {
|
||||
/** Builtin specifiers the module proxy table answers (without the `node:` prefix). */
|
||||
export const builtinModules = [
|
||||
'assert', 'async_hooks', 'buffer', 'child_process', 'crypto', 'events', 'fs', 'http', 'module',
|
||||
'net', 'os', 'path', 'process', 'stream', 'url', 'util', 'worker_threads',
|
||||
'net', 'os', 'path', 'process', 'stream', 'tty', 'url', 'util', 'worker_threads',
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* `node:tty` for the browser worker. The host has no terminal-backed file
|
||||
* descriptors, so terminal detection is always false.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Test whether a numeric file descriptor refers to a terminal.
|
||||
* @param _fd - File descriptor to inspect.
|
||||
* @returns Always false in the browser worker.
|
||||
*/
|
||||
export function isatty(_fd: number): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
/** CommonJS interop marker: the worker loader hands `default` to default imports (see ../../builtins.ts). */
|
||||
export const __esModule = true
|
||||
|
||||
/** CommonJS default export: the members `require()` hands a caller of this module. */
|
||||
export default { isatty } satisfies Partial<typeof import('node:tty')>
|
||||
@@ -34,6 +34,7 @@ import * as nodePath from './builtin_modules/implemented/path.ts'
|
||||
import * as nodePerfHooks from './builtin_modules/implemented/perf_hooks.ts'
|
||||
import * as nodeStream from './builtin_modules/implemented/stream.ts'
|
||||
import * as nodeTimersPromises from './builtin_modules/implemented/timers/promises.ts'
|
||||
import * as nodeTty from './builtin_modules/implemented/tty.ts'
|
||||
import * as nodeUrl from './builtin_modules/implemented/url.ts'
|
||||
import * as nodeUtil from './builtin_modules/implemented/util.ts'
|
||||
import * as nodeUtilTypes from './builtin_modules/implemented/util/types.ts'
|
||||
@@ -71,6 +72,7 @@ const BUILTINS: Record<string, StaticModuleFactory> = {
|
||||
sqlite: () => nodeSqlite,
|
||||
stream: () => nodeStream,
|
||||
'timers/promises': () => nodeTimersPromises,
|
||||
tty: () => nodeTty,
|
||||
url: () => nodeUrl,
|
||||
util: () => nodeUtil,
|
||||
'util/types': () => nodeUtilTypes,
|
||||
|
||||
@@ -9,13 +9,15 @@ afterEach(() => {
|
||||
|
||||
it('ignores script preload hints and executes script sources through the worker loader', async () => {
|
||||
const loadScript = vi.fn(async () => {})
|
||||
const preload = '/plugins/??app-a/client.js,app-b/client.js&rev=app'
|
||||
const bootstrap = '/plugins/??modules/client.js&rev=boot'
|
||||
|
||||
await applyIndexInjections([
|
||||
{ kind: 'script-preload', src: '/plugins/preload.js' },
|
||||
{ kind: 'script-src', placement: 'head', src: '/plugins/execute.js' },
|
||||
{ kind: 'script-preload', src: preload },
|
||||
{ kind: 'script-src', placement: 'head', src: bootstrap },
|
||||
], loadScript)
|
||||
|
||||
expect(loadScript).toHaveBeenCalledOnce()
|
||||
expect(loadScript).toHaveBeenCalledWith('/plugins/execute.js')
|
||||
expect(loadScript).toHaveBeenCalledWith(bootstrap)
|
||||
expect(document.querySelector('link[rel="preload"]')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { WorkerTunnel } from '../../src/client/client.ts'
|
||||
|
||||
type StubListener = (event: { data?: unknown }) => void
|
||||
|
||||
function stubWorker(): {
|
||||
worker: Worker
|
||||
sent: { t: string; id: number; url: string }[]
|
||||
deliver: (frame: unknown) => void
|
||||
} {
|
||||
const listeners: StubListener[] = []
|
||||
const sent: { t: string; id: number; url: string }[] = []
|
||||
return {
|
||||
worker: {
|
||||
addEventListener: (type: string, listener: StubListener) => {
|
||||
if (type === 'message') listeners.push(listener)
|
||||
},
|
||||
postMessage: (frame: unknown) => { sent.push(frame as { t: string; id: number; url: string }) },
|
||||
} as unknown as Worker,
|
||||
sent,
|
||||
deliver: (frame) => { for (const listener of listeners) listener({ data: frame }) },
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
document.head.innerHTML = ''
|
||||
})
|
||||
|
||||
it('loads a combo map through the tunnel and embeds it in the blob script', async () => {
|
||||
const { worker, sent, deliver } = stubWorker()
|
||||
const tunnel = new WorkerTunnel(worker)
|
||||
const blobs: Blob[] = []
|
||||
const revoked: string[] = []
|
||||
const NativeURL = URL
|
||||
class StubURL extends NativeURL {
|
||||
static override createObjectURL(blob: Blob): string {
|
||||
blobs.push(blob)
|
||||
return `blob:fixture-${String(blobs.length)}`
|
||||
}
|
||||
|
||||
static override revokeObjectURL(url: string): void {
|
||||
revoked.push(url)
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('URL', StubURL)
|
||||
vi.spyOn(document.head, 'append').mockImplementation((...nodes) => {
|
||||
for (const node of nodes) {
|
||||
if (typeof node !== 'string') queueMicrotask(() => { node.dispatchEvent(new Event('load')) })
|
||||
}
|
||||
})
|
||||
|
||||
const scriptUrl = '/plugins/??a/client.js,b/client.js&rev=abc'
|
||||
const mapUrl = '/plugins/??a/client.js.map,b/client.js.map&rev=abc'
|
||||
const loading = tunnel.loadBundle(scriptUrl)
|
||||
expect(sent[0]?.url).toBe(`http://localhost:3000${scriptUrl}`)
|
||||
deliver({
|
||||
t: 'res',
|
||||
id: 1,
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/javascript' },
|
||||
body: new TextEncoder().encode(`factory();\n//# sourceMappingURL=${mapUrl}\n`).buffer,
|
||||
})
|
||||
await vi.waitFor(() => { expect(sent).toHaveLength(2) })
|
||||
expect(sent[1]?.url).toBe(`http://localhost:3000${mapUrl}`)
|
||||
const map = '{"version":3,"sections":[]}'
|
||||
deliver({
|
||||
t: 'res',
|
||||
id: 2,
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: new TextEncoder().encode(map).buffer,
|
||||
})
|
||||
await loading
|
||||
|
||||
const source = await blobs[0]?.text()
|
||||
const encoded = /sourceMappingURL=data:application\/json;charset=utf-8;base64,([^\s]+)/.exec(source ?? '')?.[1]
|
||||
if (encoded === undefined) throw new Error('localized bundle has no inline source map')
|
||||
const decoded = Uint8Array.from(atob(encoded), char => char.charCodeAt(0))
|
||||
expect(new TextDecoder().decode(decoded)).toBe(map)
|
||||
expect(revoked).toEqual(['blob:fixture-1'])
|
||||
})
|
||||
@@ -69,6 +69,12 @@ describe('module identity through the loader', () => {
|
||||
const require = loaderRequire()
|
||||
expect(require('events')).toBe(require('node:events'))
|
||||
expect(require('fs')).toBe(require('node:fs'))
|
||||
expect(require('tty')).toBe(require('node:tty'))
|
||||
})
|
||||
|
||||
it('reports that worker file descriptors are not terminals', () => {
|
||||
const tty = loaderRequire()('tty') as { isatty(fd: number): boolean }
|
||||
expect(tty.isatty(2)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps class identity across those specifiers', () => {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createServer, get, request, requestListener, STATUS_CODES, whenRequestListener,
|
||||
createServer, get, request, requestListener, ServerResponse, STATUS_CODES, whenRequestListener,
|
||||
} from '../../src/node/builtin_modules/implemented/http.ts'
|
||||
import type { RequestListener } from '../../src/transport/synthetic-http.ts'
|
||||
|
||||
@@ -46,6 +46,10 @@ describe('request listener capture', () => {
|
||||
})
|
||||
|
||||
describe('binding', () => {
|
||||
it('exposes the response prototype middleware probes during module loading', () => {
|
||||
expect(ServerResponse.prototype).not.toHaveProperty('appendHeader')
|
||||
})
|
||||
|
||||
it('reports the bind through the callback the webserver fiber waits on', async () => {
|
||||
const server = createServer(listener)
|
||||
let bound = false
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/webserver/README.md
|
||||
README.md: c6abc503222fc8bf60d4b6c940eeb1f7910cc9aa
|
||||
README.zh.md: 430488869c98a86ff669e12acfaee86bae7aa8a3
|
||||
README.md: 61b18d377cae432895a58ed8c6a80cfdee886d24
|
||||
README.zh.md: 7daee31433b8cba1f2d03b21ed5707328c7c7d61
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Web HTTP and upgrade-route registration plugin (default-exported `WebServer`, config `{host, port}`): a `node:http` server that listens on activation and provides `ctx.webServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `registerFallback(handler)` registers the one handler for requests that match no named route. A second registration throws; the SPA dist server [`dsh-host-frontend-static`](../frontend-static/README.md) is the shipped owner, and the server returns 404 while none is registered. Index startup inputs are structured rows: `collectIndexInjections()` gathers a fresh `IndexInjection` table over one `webserver/index-inject` emit per call, and `renderIndex(html)` renders the rows into an index.html body before applying the raw `tapIndex(transform)` transforms in registration order (`applyIndexTaps(html)`, the escape hatch for markup no row expresses); `script-preload` rows render advisory classic-script preload links. The fallback handler calls `renderIndex` on every index response, and a static deployment ships the same rows over its boot payload. `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP match order is fixed: exact over the whole table, then longest prefix, then the fallback handler. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics.
|
||||
Web HTTP and upgrade-route registration plugin (default-exported `WebServer`, config `{host, port, compression?, compressionLevel?, compressionThresholdBytes?}`): a `node:http` server that listens on activation and provides `ctx.webServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `registerFallback(handler)` registers the one handler for requests that match no named route. A second registration throws; the SPA dist server [`dsh-host-frontend-static`](../frontend-static/README.md) is the shipped owner, and the server returns 404 while none is registered. Index startup inputs are structured rows: `collectIndexInjections()` gathers a fresh `IndexInjection` table over one `webserver/index-inject` emit per call, and `renderIndex(html)` renders the rows into an index.html body before applying the raw `tapIndex(transform)` transforms in registration order (`applyIndexTaps(html)`, the escape hatch for markup no row expresses); `script-preload` rows render advisory classic-script preload links. The fallback handler calls `renderIndex` on every index response, and a static deployment ships the same rows over its boot payload. `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP match order is fixed: exact over the whole table, then longest prefix, then the fallback handler. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics.
|
||||
|
||||
`compression: 'gzip'` wraps socket-backed HTTP responses without changing route APIs. The client must prefer gzip and the media type must be compressible; known response lengths below `compressionThresholdBytes` stay identity, while unknown-length streams are eligible immediately. `compressionLevel` controls DEFLATE effort. Existing content encodings, `Cache-Control: no-transform`, range responses, SSE, ZIP, and the packaged `.gz` Worker image stay unmodified. The shipped Web bundle enables level 1 with a 1024-byte threshold; other compositions default to `compression: 'none'`. The Web Worker tunnel does not carry the browser-managed `Accept-Encoding` header, so its synthetic responses remain identity bytes.
|
||||
|
||||
The package knows no harness concepts and serves no files: the `/api` HTTP bridge and downlink WebSockets are routes owned by the connection plugin, plugin bundles and the HMR event stream are routes owned by the modules/hmr plugins, and dist serving belongs to the fallback owner. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure). This server serves browsers only; Electron loads dist over `file://` and carries fetch over an IPC bridge. This package never prints; the URL line belongs to the shell.
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Web HTTP 与 upgrade route 注册插件(默认导出 `WebServer`,配置为 `{host, port}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.webServer`。`register(route)` 添加具名的 `exact`/`prefix` HTTP route;`registerUpgrade(route)` 添加精确 pathname 的 upgrade route;同一张表内的重复路径会抛错,因为 route 模式是组合层约定,冲突即配置错误;两者返回的 disposer 都会移除注册。`registerFallback(handler)` 注册一个 handler,处理所有未被具名 route 命中的请求。第二次注册会抛错;随附的 SPA dist 服务器 [`dsh-host-frontend-static`](../frontend-static/README.zh.md) 是该 handler 的所有者,没有注册 handler 时服务器返回 404。index 的启动输入是结构化行:`collectIndexInjections()` 每次调用经一次 `webserver/index-inject` emit 现收一张全新的 `IndexInjection` 表,`renderIndex(html)` 先把行渲染进 index.html 响应体,再按注册顺序应用原始的 `tapIndex(transform)` 转换(`applyIndexTaps(html)`,行无法表达的标记的逃生口);`script-preload` 行渲染为 classic script 的提示性预加载链接。fallback handler 在每次 index 响应时调用 `renderIndex`,静态部署则把同一批行经 boot 载荷下发。`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后交给 fallback handler。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不影响请求处理。
|
||||
Web HTTP 与 upgrade route 注册插件(默认导出 `WebServer`,配置为 `{host, port, compression?, compressionLevel?, compressionThresholdBytes?}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.webServer`。`register(route)` 添加具名的 `exact`/`prefix` HTTP route;`registerUpgrade(route)` 添加精确 pathname 的 upgrade route;同一张表内的重复路径会抛错,因为 route 模式是组合层约定,冲突即配置错误;两者返回的 disposer 都会移除注册。`registerFallback(handler)` 注册一个 handler,处理所有未被具名 route 命中的请求。第二次注册会抛错;随附的 SPA dist 服务器 [`dsh-host-frontend-static`](../frontend-static/README.zh.md) 是该 handler 的所有者,没有注册 handler 时服务器返回 404。index 的启动输入是结构化行:`collectIndexInjections()` 每次调用经一次 `webserver/index-inject` emit 现收一张全新的 `IndexInjection` 表,`renderIndex(html)` 先把行渲染进 index.html 响应体,再按注册顺序应用原始的 `tapIndex(transform)` 转换(`applyIndexTaps(html)`,行无法表达的标记的逃生口);`script-preload` 行渲染为 classic script 的提示性预加载链接。fallback handler 在每次 index 响应时调用 `renderIndex`,静态部署则把同一批行经 boot 载荷下发。`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后交给 fallback handler。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不影响请求处理。
|
||||
|
||||
`compression: 'gzip'` 会包装基于 socket 的 HTTP 响应,而不改变 route API。客户端必须偏好 gzip,且媒体类型必须可压缩;已知长度低于 `compressionThresholdBytes` 的响应保持 identity,未知长度的 stream 则直接具备压缩资格。`compressionLevel` 控制 DEFLATE 强度。已有内容编码、`Cache-Control: no-transform`、范围响应、SSE、ZIP 与打包后的 `.gz` Worker 镜像均保持原样。随附的 Web 组合启用 level 1 和 1024 字节阈值;其他组合默认使用 `compression: 'none'`。Web Worker 隧道不携带浏览器管理的 `Accept-Encoding` 请求头,因此其合成响应仍为 identity 字节。
|
||||
|
||||
该包不了解任何 harness 概念,也不提供任何文件服务:`/api` HTTP 桥接与下行 WebSocket 是 connection 插件的 route,插件 bundle 与 HMR(热模块替换)事件流是 modules/hmr 插件的 route,dist 服务则属于 fallback 持有者。upgrade handler 拥有协议握手与连接内容;webserver 只交付原始 socket 与 request。`host` 只接受 `127.0.0.1`(默认安全姿态)和 `0.0.0.0`(有意向网络开放)。该服务器只服务浏览器;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch。该包从不打印内容;URL 行属于 shell。
|
||||
|
||||
|
||||
@@ -36,10 +36,14 @@
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
"@deepseek-ai/schemastery": "workspace:^",
|
||||
"compression": "^1.8.1",
|
||||
"negotiator": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^"
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/negotiator": "^0.6.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
/**
|
||||
* @deepseek-ai/dsh-host-webserver — Web route-registration plugin: a node:http
|
||||
* server plus the `webServer` service (HTTP and upgrade route registries, the
|
||||
* structured index injection table with raw transform taps behind it, and the
|
||||
* single fallback seat for everything no route claims). Knows no harness concepts and serves no files; the composing
|
||||
* application's frontend plugin owns dist serving through the fallback hook.
|
||||
* Web shape only — Electron loads dist over file:// and carries fetch over an
|
||||
* IPC bridge. This package never prints: the URL line belongs to the shell.
|
||||
* @deepseek-ai/dsh-host-webserver — node:http route registration with optional
|
||||
* gzip, index injection, and one fallback seat. It knows no harness concepts
|
||||
* and serves no files; the composing application owns dist serving. Electron
|
||||
* uses file:// plus IPC instead, and this package never prints the URL.
|
||||
* Route handlers retain direct response ownership.
|
||||
*/
|
||||
|
||||
import { createServer } from 'node:http'
|
||||
@@ -14,6 +12,8 @@ import type { AddressInfo } from 'node:net'
|
||||
import type { Duplex } from 'node:stream'
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import compressionMiddleware from 'compression'
|
||||
import Negotiator from 'negotiator'
|
||||
import { renderIndexInjections, type IndexInjection } from './injections.ts'
|
||||
|
||||
export { renderIndexInjections } from './injections.ts'
|
||||
@@ -55,12 +55,63 @@ export interface WebUpgradeRoute {
|
||||
handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise<void>
|
||||
}
|
||||
|
||||
/** Gateway config: the listen address. */
|
||||
/** Web server listen and response-compression config. */
|
||||
export interface Config {
|
||||
/** Listen host; the two supported values are loopback and all-interfaces. */
|
||||
host: '127.0.0.1' | '0.0.0.0'
|
||||
/** Listen port; zero requests an OS-assigned port. */
|
||||
port: number
|
||||
/** Response compression for socket-backed HTTP requests. @default 'none' */
|
||||
compression?: 'none' | 'gzip'
|
||||
/** Gzip DEFLATE level from 0 through 9. @default 1 */
|
||||
compressionLevel?: number
|
||||
/** Minimum known response length eligible for gzip; unknown-length streams are eligible. @default 1024 */
|
||||
compressionThresholdBytes?: number
|
||||
}
|
||||
|
||||
const DEFAULT_COMPRESSION = 'none' as const
|
||||
const DEFAULT_COMPRESSION_LEVEL = 1
|
||||
const DEFAULT_COMPRESSION_THRESHOLD_BYTES = 1024
|
||||
|
||||
interface ResolvedConfig extends Config {
|
||||
compression: 'none' | 'gzip'
|
||||
compressionLevel: number
|
||||
compressionThresholdBytes: number
|
||||
}
|
||||
|
||||
type NodeMiddleware = (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
next: () => void,
|
||||
) => void
|
||||
|
||||
function createGzipMiddleware(config: ResolvedConfig): NodeMiddleware {
|
||||
// `compression` is typed for Express, but its runtime uses only the
|
||||
// node:http request and response members supplied here.
|
||||
const middleware = compressionMiddleware({
|
||||
level: config.compressionLevel,
|
||||
threshold: config.compressionThresholdBytes,
|
||||
filter(request, response) {
|
||||
if (response.getHeader('content-range') !== undefined) return false
|
||||
const contentType = response.getHeader('content-type')
|
||||
if (typeof contentType === 'string' && contentType.toLowerCase().startsWith('text/event-stream')) return false
|
||||
return compressionMiddleware.filter(request, response)
|
||||
},
|
||||
}) as unknown as NodeMiddleware
|
||||
|
||||
return (req, res, next) => {
|
||||
// The Web Worker tunnel has no socket and transfers identity bytes.
|
||||
if ((res as { socket?: unknown }).socket === undefined) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
const encoding = new Negotiator(req).encoding(['gzip', 'identity'])
|
||||
const gzipRequest = Object.create(req) as IncomingMessage
|
||||
Object.defineProperty(gzipRequest, 'headers', {
|
||||
value: { ...req.headers, 'accept-encoding': encoding === 'gzip' ? 'gzip' : 'identity' },
|
||||
})
|
||||
middleware(gzipRequest, res, next)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,6 +125,9 @@ export class WebServer extends Service {
|
||||
static Config: z<Config> = z.object({
|
||||
host: z.union([z.const('127.0.0.1'), z.const('0.0.0.0')]).required(),
|
||||
port: z.natural().max(65535).required(),
|
||||
compression: z.union([z.const('none'), z.const('gzip')]).default(DEFAULT_COMPRESSION),
|
||||
compressionLevel: z.number().step(1).min(0).max(9).default(DEFAULT_COMPRESSION_LEVEL),
|
||||
compressionThresholdBytes: z.natural().default(DEFAULT_COMPRESSION_THRESHOLD_BYTES),
|
||||
})
|
||||
|
||||
private readonly exact = new Map<string, WebRoute>()
|
||||
@@ -84,9 +138,12 @@ export class WebServer extends Service {
|
||||
private fallback: WebRoute['handler'] | undefined
|
||||
private server!: Server
|
||||
private listenedPort!: number
|
||||
private readonly gzip: NodeMiddleware | undefined
|
||||
|
||||
constructor(ctx: Context, private config: Config) {
|
||||
super(ctx, 'webServer')
|
||||
const resolved = config as ResolvedConfig
|
||||
this.gzip = resolved.compression === 'gzip' ? createGzipMiddleware(resolved) : undefined
|
||||
}
|
||||
|
||||
/** The listening port (the OS-assigned value when config.port is 0). */
|
||||
@@ -183,15 +240,19 @@ export class WebServer extends Service {
|
||||
// client dropping mid-body). Per-request failures log and answer 400 —
|
||||
// never a process exit.
|
||||
this.server = createServer((req, res) => {
|
||||
handle(req, res).catch((err: unknown) => {
|
||||
this.ctx.logger.warn(err instanceof Error ? err : new Error(String(err)))
|
||||
if (res.headersSent) {
|
||||
res.destroy()
|
||||
return
|
||||
}
|
||||
res.writeHead(400)
|
||||
res.end()
|
||||
})
|
||||
const next = (): void => {
|
||||
void handle(req, res).catch((err: unknown) => {
|
||||
this.ctx.logger.warn(err instanceof Error ? err : new Error(String(err)))
|
||||
if (res.headersSent) {
|
||||
res.destroy()
|
||||
return
|
||||
}
|
||||
res.writeHead(400)
|
||||
res.end()
|
||||
})
|
||||
}
|
||||
if (this.gzip === undefined) next()
|
||||
else this.gzip(req, res, next)
|
||||
})
|
||||
this.server.on('upgrade', (req, socket, head) => {
|
||||
const onError = (error: Error): void => {
|
||||
|
||||
@@ -28,7 +28,7 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
/** Write a cordis.yml with one webserver row, then boot it through the real Loader. */
|
||||
async function loadComposition(port = 0): Promise<Context> {
|
||||
async function loadComposition(port = 0, gzip = false): Promise<Context> {
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-webserver-loader-'))
|
||||
const configPath = join(root, 'cordis.yml')
|
||||
await writeFile(configPath, [
|
||||
@@ -36,6 +36,13 @@ async function loadComposition(port = 0): Promise<Context> {
|
||||
' config:',
|
||||
" host: '127.0.0.1'",
|
||||
` port: ${String(port)}`,
|
||||
...(gzip
|
||||
? [
|
||||
' compression: gzip',
|
||||
' compressionLevel: 1',
|
||||
' compressionThresholdBytes: 16',
|
||||
]
|
||||
: []),
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
@@ -62,9 +69,13 @@ async function loadComposition(port = 0): Promise<Context> {
|
||||
}
|
||||
|
||||
/** GET (by default) one path against the running server; returns status plus a body prefix. */
|
||||
async function request(port: number, path: string, init?: RequestInit): Promise<{ status: number; body: string }> {
|
||||
async function request(
|
||||
port: number,
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<{ status: number; body: string; headers: Headers }> {
|
||||
const response = await fetch(`http://127.0.0.1:${String(port)}${path}`, init)
|
||||
return { status: response.status, body: (await response.text()).slice(0, 80) }
|
||||
return { status: response.status, body: (await response.text()).slice(0, 80), headers: response.headers }
|
||||
}
|
||||
|
||||
/** Open one raw upgrade request and return after the handler writes its response. */
|
||||
@@ -86,6 +97,98 @@ async function upgrade(port: number, path: string): Promise<ReturnType<typeof co
|
||||
}
|
||||
|
||||
describe('real Loader composition', () => {
|
||||
it('applies gzip only to eligible socket-backed HTTP responses', { timeout: 60_000 }, async () => {
|
||||
expect(HttpServer.Config({ host: '127.0.0.1', port: 0 })).toEqual({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
compression: 'none',
|
||||
compressionLevel: 1,
|
||||
compressionThresholdBytes: 1024,
|
||||
})
|
||||
expect(() => HttpServer.Config({
|
||||
host: '127.0.0.1', port: 0, compressionLevel: 10,
|
||||
})).toThrow()
|
||||
|
||||
const loaded = await loadComposition(0, true)
|
||||
const server = loaded.webServer
|
||||
const body = 'compressible response '.repeat(8)
|
||||
server.register({
|
||||
kind: 'exact',
|
||||
path: '/text',
|
||||
handler: (_req, res) => {
|
||||
res.writeHead(200, {
|
||||
'content-type': 'text/plain; charset=utf-8',
|
||||
'content-length': String(Buffer.byteLength(body)),
|
||||
})
|
||||
res.end(body)
|
||||
},
|
||||
})
|
||||
server.register({
|
||||
kind: 'exact',
|
||||
path: '/stream',
|
||||
handler: (_req, res) => {
|
||||
res.writeHead(200, { 'content-type': 'application/json' })
|
||||
res.write(body.slice(0, 40))
|
||||
res.end(body.slice(40))
|
||||
},
|
||||
})
|
||||
server.register({
|
||||
kind: 'exact',
|
||||
path: '/small',
|
||||
handler: (_req, res) => {
|
||||
res.writeHead(200, { 'content-type': 'text/plain', 'content-length': '5' })
|
||||
res.end('small')
|
||||
},
|
||||
})
|
||||
server.register({
|
||||
kind: 'exact',
|
||||
path: '/events',
|
||||
handler: (_req, res) => {
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
res.end(body)
|
||||
},
|
||||
})
|
||||
server.register({
|
||||
kind: 'exact',
|
||||
path: '/archive',
|
||||
handler: (_req, res) => {
|
||||
res.writeHead(200, { 'content-type': 'application/gzip' })
|
||||
res.end(body)
|
||||
},
|
||||
})
|
||||
server.register({
|
||||
kind: 'exact',
|
||||
path: '/range',
|
||||
handler: (_req, res) => {
|
||||
res.writeHead(206, { 'content-type': 'text/plain', 'content-range': 'bytes 0-15/160' })
|
||||
res.end(body.slice(0, 16))
|
||||
},
|
||||
})
|
||||
|
||||
const compressed = await request(server.port, '/text', { headers: { 'accept-encoding': 'br, gzip, deflate' } })
|
||||
expect(compressed).toMatchObject({ status: 200, body: body.slice(0, 80) })
|
||||
expect(compressed.headers.get('content-encoding')).toBe('gzip')
|
||||
expect(compressed.headers.get('content-length')).toBeNull()
|
||||
expect(compressed.headers.get('vary')).toBe('Accept-Encoding')
|
||||
const streamed = await request(server.port, '/stream', { headers: { 'accept-encoding': 'gzip' } })
|
||||
expect(streamed).toMatchObject({ body: body.slice(0, 80) })
|
||||
expect(streamed.headers.get('content-encoding')).toBe('gzip')
|
||||
expect((await request(server.port, '/small', { headers: { 'accept-encoding': 'gzip' } }))
|
||||
.headers.get('content-encoding')).toBeNull()
|
||||
|
||||
const identity = await request(server.port, '/text', {
|
||||
headers: { 'accept-encoding': 'gzip;q=0.5, identity;q=1' },
|
||||
})
|
||||
expect(identity.headers.get('content-encoding')).toBeNull()
|
||||
expect(identity.headers.get('vary')).toBe('Accept-Encoding')
|
||||
expect((await request(server.port, '/events', { headers: { 'accept-encoding': 'gzip' } }))
|
||||
.headers.get('content-encoding')).toBeNull()
|
||||
expect((await request(server.port, '/archive', { headers: { 'accept-encoding': 'gzip' } }))
|
||||
.headers.get('content-encoding')).toBeNull()
|
||||
expect((await request(server.port, '/range', { headers: { 'accept-encoding': 'gzip' } }))
|
||||
.headers.get('content-encoding')).toBeNull()
|
||||
})
|
||||
|
||||
// Real-Loader composition resolves workspace packages through tsx at test
|
||||
// time; first resolution after the host/client program split is slow enough
|
||||
// to trip the default 5s budget on cold caches.
|
||||
|
||||
Generated
+142
@@ -5720,6 +5720,12 @@ importers:
|
||||
'@deepseek-ai/schemastery':
|
||||
specifier: link:../../../vendor/schemastery
|
||||
version: link:../../../vendor/schemastery
|
||||
compression:
|
||||
specifier: ^1.8.1
|
||||
version: 1.8.1
|
||||
negotiator:
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.0
|
||||
devDependencies:
|
||||
'@deepseek-ai/cordis':
|
||||
specifier: workspace:^
|
||||
@@ -5727,6 +5733,12 @@ importers:
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../runtime-diagnostics/invariants
|
||||
'@types/compression':
|
||||
specifier: ^1.8.1
|
||||
version: 1.8.1
|
||||
'@types/negotiator':
|
||||
specifier: ^0.6.5
|
||||
version: 0.6.5
|
||||
|
||||
packages/identity/anonymous-user-id:
|
||||
devDependencies:
|
||||
@@ -12540,9 +12552,18 @@ packages:
|
||||
'@types/babel__traverse@7.28.0':
|
||||
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
|
||||
|
||||
'@types/body-parser@1.19.6':
|
||||
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
|
||||
'@types/compression@1.8.1':
|
||||
resolution: {integrity: sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==}
|
||||
|
||||
'@types/connect@3.4.38':
|
||||
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
||||
|
||||
'@types/d3-array@3.2.2':
|
||||
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
||||
|
||||
@@ -12648,12 +12669,21 @@ packages:
|
||||
'@types/estree@1.0.9':
|
||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||
|
||||
'@types/express-serve-static-core@5.1.3':
|
||||
resolution: {integrity: sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==}
|
||||
|
||||
'@types/express@5.0.6':
|
||||
resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==}
|
||||
|
||||
'@types/geojson@7946.0.16':
|
||||
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
|
||||
|
||||
'@types/hast@3.0.5':
|
||||
resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==}
|
||||
|
||||
'@types/http-errors@2.0.5':
|
||||
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
|
||||
|
||||
'@types/js-yaml@4.0.9':
|
||||
resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
|
||||
|
||||
@@ -12684,6 +12714,9 @@ packages:
|
||||
'@types/ms@2.1.0':
|
||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||
|
||||
'@types/negotiator@0.6.5':
|
||||
resolution: {integrity: sha512-MPOlB48mfWhoUlynY0ga7CFsXIPcH6vGPkjzXMn2p+4PH1QUyn2KPtw0hrLLmO6SaX4zse3X6h2x/083vveAlA==}
|
||||
|
||||
'@types/node@22.20.0':
|
||||
resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==}
|
||||
|
||||
@@ -12699,6 +12732,12 @@ packages:
|
||||
'@types/prop-types@15.7.15':
|
||||
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
||||
|
||||
'@types/qs@6.15.1':
|
||||
resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}
|
||||
|
||||
'@types/range-parser@1.2.7':
|
||||
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
|
||||
|
||||
'@types/react-dom@18.3.7':
|
||||
resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
|
||||
peerDependencies:
|
||||
@@ -12713,6 +12752,12 @@ packages:
|
||||
'@types/retry@0.12.0':
|
||||
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
|
||||
|
||||
'@types/send@1.2.1':
|
||||
resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
|
||||
|
||||
'@types/serve-static@2.2.0':
|
||||
resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
|
||||
|
||||
'@types/spdx-expression-parse@4.0.0':
|
||||
resolution: {integrity: sha512-odQzy87phelGS4inXOzjmusx4hoCVD0IbxUANxHzVkmTzMRTNnUPoq1urIl7S1qf09KcDWKLFIftPmLtgbsAHA==}
|
||||
|
||||
@@ -13199,6 +13244,14 @@ packages:
|
||||
compare-versions@6.1.1:
|
||||
resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==}
|
||||
|
||||
compressible@2.0.18:
|
||||
resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
compression@1.8.1:
|
||||
resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
content-disposition@1.1.0:
|
||||
resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -13421,6 +13474,14 @@ packages:
|
||||
dayjs@1.11.21:
|
||||
resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==}
|
||||
|
||||
debug@2.6.9:
|
||||
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
@@ -14589,6 +14650,9 @@ packages:
|
||||
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
ms@2.0.0:
|
||||
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
@@ -14600,6 +14664,10 @@ packages:
|
||||
natural-compare@1.4.0:
|
||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||
|
||||
negotiator@0.6.4:
|
||||
resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
negotiator@1.0.0:
|
||||
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -14698,6 +14766,10 @@ packages:
|
||||
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
on-headers@1.1.0:
|
||||
resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
once@1.4.0:
|
||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||
|
||||
@@ -17817,11 +17889,25 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/types': 7.29.7
|
||||
|
||||
'@types/body-parser@1.19.6':
|
||||
dependencies:
|
||||
'@types/connect': 3.4.38
|
||||
'@types/node': 22.20.0
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
dependencies:
|
||||
'@types/deep-eql': 4.0.2
|
||||
assertion-error: 2.0.1
|
||||
|
||||
'@types/compression@1.8.1':
|
||||
dependencies:
|
||||
'@types/express': 5.0.6
|
||||
'@types/node': 22.20.0
|
||||
|
||||
'@types/connect@3.4.38':
|
||||
dependencies:
|
||||
'@types/node': 22.20.0
|
||||
|
||||
'@types/d3-array@3.2.2': {}
|
||||
|
||||
'@types/d3-axis@3.0.6':
|
||||
@@ -17949,12 +18035,27 @@ snapshots:
|
||||
|
||||
'@types/estree@1.0.9': {}
|
||||
|
||||
'@types/express-serve-static-core@5.1.3':
|
||||
dependencies:
|
||||
'@types/node': 22.20.0
|
||||
'@types/qs': 6.15.1
|
||||
'@types/range-parser': 1.2.7
|
||||
'@types/send': 1.2.1
|
||||
|
||||
'@types/express@5.0.6':
|
||||
dependencies:
|
||||
'@types/body-parser': 1.19.6
|
||||
'@types/express-serve-static-core': 5.1.3
|
||||
'@types/serve-static': 2.2.0
|
||||
|
||||
'@types/geojson@7946.0.16': {}
|
||||
|
||||
'@types/hast@3.0.5':
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
'@types/http-errors@2.0.5': {}
|
||||
|
||||
'@types/js-yaml@4.0.9': {}
|
||||
|
||||
'@types/jsdom@28.0.3':
|
||||
@@ -17985,6 +18086,8 @@ snapshots:
|
||||
|
||||
'@types/ms@2.1.0': {}
|
||||
|
||||
'@types/negotiator@0.6.5': {}
|
||||
|
||||
'@types/node@22.20.0':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
@@ -18001,6 +18104,10 @@ snapshots:
|
||||
|
||||
'@types/prop-types@15.7.15': {}
|
||||
|
||||
'@types/qs@6.15.1': {}
|
||||
|
||||
'@types/range-parser@1.2.7': {}
|
||||
|
||||
'@types/react-dom@18.3.7(@types/react@18.3.31)':
|
||||
dependencies:
|
||||
'@types/react': 18.3.31
|
||||
@@ -18016,6 +18123,15 @@ snapshots:
|
||||
|
||||
'@types/retry@0.12.0': {}
|
||||
|
||||
'@types/send@1.2.1':
|
||||
dependencies:
|
||||
'@types/node': 22.20.0
|
||||
|
||||
'@types/serve-static@2.2.0':
|
||||
dependencies:
|
||||
'@types/http-errors': 2.0.5
|
||||
'@types/node': 22.20.0
|
||||
|
||||
'@types/spdx-expression-parse@4.0.0': {}
|
||||
|
||||
'@types/tough-cookie@4.0.5': {}
|
||||
@@ -18511,6 +18627,22 @@ snapshots:
|
||||
|
||||
compare-versions@6.1.1: {}
|
||||
|
||||
compressible@2.0.18:
|
||||
dependencies:
|
||||
mime-db: 1.54.0
|
||||
|
||||
compression@1.8.1:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
compressible: 2.0.18
|
||||
debug: 2.6.9
|
||||
negotiator: 0.6.4
|
||||
on-headers: 1.1.0
|
||||
safe-buffer: 5.2.1
|
||||
vary: 1.1.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
content-disposition@1.1.0: {}
|
||||
|
||||
content-type@1.0.5: {}
|
||||
@@ -18752,6 +18884,10 @@ snapshots:
|
||||
|
||||
dayjs@1.11.21: {}
|
||||
|
||||
debug@2.6.9:
|
||||
dependencies:
|
||||
ms: 2.0.0
|
||||
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
@@ -20206,12 +20342,16 @@ snapshots:
|
||||
|
||||
mri@1.2.0: {}
|
||||
|
||||
ms@2.0.0: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
nanoid@3.3.12: {}
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
|
||||
negotiator@0.6.4: {}
|
||||
|
||||
negotiator@1.0.0: {}
|
||||
|
||||
node-addon-api@7.1.1: {}
|
||||
@@ -20297,6 +20437,8 @@ snapshots:
|
||||
dependencies:
|
||||
ee-first: 1.1.1
|
||||
|
||||
on-headers@1.1.0: {}
|
||||
|
||||
once@1.4.0:
|
||||
dependencies:
|
||||
wrappy: 1.0.2
|
||||
|
||||
Reference in New Issue
Block a user