mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
Merge pull request #6618 from deepseek-harness/worktree-pluginassets
feat(client): serve plugin package assets by URL instead of data URLs
This commit is contained in:
commit
07a4a1713a
71 files changed
+2710
-319
No files matched your search
+4
-4
@@ -21,8 +21,8 @@
|
||||
en: 0a4240d88cca240a
|
||||
zh: 8f5f1527caf571cd
|
||||
/agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/decision/the-loading-flow-end-to-end:
|
||||
en: 7dac236d6add3c3c
|
||||
zh: 2a43a923a0646bb0
|
||||
en: 15fb56befd328339
|
||||
zh: 0ffb10ada7e52805
|
||||
/agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/decision/live-graph-reconciliation:
|
||||
en: 37e2fa1fe5cb2b95
|
||||
zh: 80e513db87709cb4
|
||||
@@ -33,8 +33,8 @@
|
||||
en: 94ca0d09e6fd1e3a
|
||||
zh: d5949a2bdec795ba
|
||||
/agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/consequences:
|
||||
en: 6a96d9ac06c222e5
|
||||
zh: b2814ab58348e831
|
||||
en: 16e1f2467c559315
|
||||
zh: a714ec6ff2722cf1
|
||||
/agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/alternatives-considered:
|
||||
en: b8f036f50cff7cd8
|
||||
zh: 6d623c4a1f4314b1
|
||||
@@ -56,7 +56,7 @@ 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. `auditStartupEntries` reports failed imports, activation errors with their original stacks, and pending dependencies; optional entries warn and required entries reject startup ([startup policy](2026-09-09-consumer-owned-startup-strictness.md)).
|
||||
2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) resolves each live Loader entry through the same `name` and owning-tree `baseUrl` inputs that imported its Host face, then reads the nearest owning package.json `dsh.client` declaration and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`. The manifest package name is the browser module identity even when an overlay names a relative source or built entry file. Distinct active Loader sources resolving to one package name fail composition; after one source unloads, the surviving source supplies the row without a fiber restart. The row's three optional fields come from manifests, never hand-copied. Composition orders requested dynamic rows before their consumers, rejects synchronous request cycles, and assigns every row to exactly one initial batch. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the Host audit reports either error from the FAILED fiber.
|
||||
3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per entry name and owning-tree base URL for the process lifetime and changed bundle publication 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 hash their pre-read filesystem metadata; startup combo revisions hash the ordered row identities and revisions, 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.
|
||||
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, rereading the package manifest and bundle metadata on each reconciliation, so an entry restart publishes a changed declaration or bundle and an unchanged one publishes nothing; a bundle rebuilt without an entry restart is published through `rebuilt(id)` ([package asset decision](2026-10-19-client-plugin-package-assets.md)). 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 hash their pre-read filesystem metadata; startup combo revisions hash the ordered row identities and revisions, 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.
|
||||
|
||||
@@ -102,7 +102,7 @@ The current package inventory and build forms live in the [client shell layering
|
||||
|
||||
## Consequences
|
||||
|
||||
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.
|
||||
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 two replaceable carrier hooks: `loadBundle` for bundle bytes and `resolveAsset` for package-asset references.
|
||||
|
||||
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 bundle snapshots and lazy response plans for the current graph and one prior startup generation. Script and map bodies are cached after their first `GET`, so memory grows with bundle snapshots and requested response bodies. Each materialized response stays fixed at its URL; a prior-generation map first requested after a rebuild reads the current map file.
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ Host 会快照每个已构建插件入口 bundle,并把每个调度阶段的
|
||||
|
||||
1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,包括无条件挂载的 `client-hmr` 行。`auditStartupEntries` 报告 import 失败、带原始 stack 的激活错误,以及待满足的依赖;optional entry 输出 warning,required entry 则使启动失败([启动策略](2026-09-09-consumer-owned-startup-strictness.zh.md))。
|
||||
2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)使用 Host face import 时相同的 `name` 与所属 tree `baseUrl` 解析每个 live Loader entry,再读取最近归属 package.json 的 `dsh.client` 声明并组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`。即使 overlay 指向相对的 source 或 built entry 文件,manifest 包名仍是浏览器模块身份。若不同的 active Loader source 解析到同一包名,组合会失败;一个来源卸载后,仍存活的来源无需重启 fiber 即可提供该 row。Row 的三个可选字段都来自 manifest,永不人肉抄写。组合会把被请求的动态图 row 排到消费者之前、拒绝同步请求环,并把每个 row 恰好分配给一个初始批次。它会拒绝没有已构建 `./client` bundle 的已声明插件,并把它们的 package/path 行归到一条源码构建要求下;畸形声明字段同样会让激活失败,Host 检查会从 FAILED fiber 报告这两类错误。
|
||||
3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按 entry 名与所属 tree base URL 缓存至进程结束,变化后 bundle 的发布只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。初始 row 对读取前的文件系统元数据求哈希;启动 combo revision 对有序 row 的身份与 revision 求哈希,row 与批次描述再共同哈希进 `graph.rev`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知;modules 会注册 combo 路由并贡献结构化 index 注入行。
|
||||
3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,每次对账都重新读取包 manifest 与 bundle 元数据,因此条目重启会发布变化后的声明或 bundle,未变化时不发布;没有伴随条目重启的 bundle 重建经 `rebuilt(id)` 发布([包资源决策](2026-10-19-client-plugin-package-assets.zh.md))。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。初始 row 对读取前的文件系统元数据求哈希;启动 combo revision 对有序 row 的身份与 revision 求哈希,row 与批次描述再共同哈希进 `graph.rev`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知;modules 会注册 combo 路由并贡献结构化 index 注入行。
|
||||
|
||||
为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个在仓库中声明了 dsh.client 的包,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。
|
||||
|
||||
@@ -102,7 +102,7 @@ Bootstrap 替换会在失效或卸载前被拒绝:模块系统保留其初始
|
||||
|
||||
## Consequences
|
||||
|
||||
Wire 两侧运行同一份治理实现;浏览器特有层只包含一套模块系统和一个重载插件。动态包只有一种产物形态,因此纯度检查覆盖全部动态包。Cordis 依赖、模块请求与启动档位都与其所有者——manifest——同住,负责组合的 app 只握名册。Host graph 校验与递归请求到达使同步 factory 依赖保持显式。浏览器原生 script 装载保留插件网络资源、生成 bundle 与 TypeScript/TSX 源码之间的标准映射,模块系统也只保留一个可替换的 `loadBundle` 钩子。
|
||||
Wire 两侧运行同一份治理实现;浏览器特有层只包含一套模块系统和一个重载插件。动态包只有一种产物形态,因此纯度检查覆盖全部动态包。Cordis 依赖、模块请求与启动档位都与其所有者——manifest——同住,负责组合的 app 只握名册。Host graph 校验与递归请求到达使同步 factory 依赖保持显式。浏览器原生 script 装载保留插件网络资源、生成 bundle 与 TypeScript/TSX 源码之间的标准映射,模块系统只保留两个可替换的载体钩子:负责 bundle 字节的 `loadBundle` 和负责包资源引用的 `resolveAsset`。
|
||||
|
||||
接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;graph `inject` row 指导 factory 到达,但服务可用性仍是激活权威,因此不匹配会在 settled 扫描时浮出;静态 UI 库保留直接实体导出;每个 bundle 多出一份 sourcemap 产物,外部 script 失败也只能给出粗粒度 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。Host 为当前图及上一代启动图保留 bundle 快照与惰性响应计划。脚本和 map body 在首次 `GET` 后缓存,因此内存随 bundle 快照和已请求的响应 body 增长。每份已物化响应在其 URL 下保持固定;若上一代 map 在重建后才首次被请求,则会读取当前 map 文件。
|
||||
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
# Bilingual-pair consistency record for 2026-10-19-client-plugin-package-assets.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-19-client-plugin-package-assets.md
|
||||
/agent-note-client-plugin-package-assets-served-by-url:
|
||||
en: 5505b948511a2d52
|
||||
zh: dae985d67e5c5935
|
||||
/agent-note-client-plugin-package-assets-served-by-url/problem:
|
||||
en: 159de908b09780eb
|
||||
zh: 8d15ca2f15b8846a
|
||||
/agent-note-client-plugin-package-assets-served-by-url/decision:
|
||||
en: 8ffcd355ec5e8271
|
||||
zh: 2932530bf783e3f7
|
||||
/agent-note-client-plugin-package-assets-served-by-url/decision/references-and-file-names:
|
||||
en: 704267953c123629
|
||||
zh: 860997024ad7825e
|
||||
/agent-note-client-plugin-package-assets-served-by-url/decision/build:
|
||||
en: 485013b2b6778fa2
|
||||
zh: 7e5cb238bb2b8585
|
||||
/agent-note-client-plugin-package-assets-served-by-url/decision/host-route:
|
||||
en: 151b7300f5b27a9f
|
||||
zh: fae9adff942c2ce8
|
||||
/agent-note-client-plugin-package-assets-served-by-url/decision/webworker-preview:
|
||||
en: db1c7b2a0b916804
|
||||
zh: 0edf4123667d8e2e
|
||||
/agent-note-client-plugin-package-assets-served-by-url/decision/changes-and-package-manifests:
|
||||
en: eedff52d04763c50
|
||||
zh: 203bfcf81242b8fe
|
||||
/agent-note-client-plugin-package-assets-served-by-url/decision/gates-and-migration:
|
||||
en: 1e4d83d0de2d47c9
|
||||
zh: dc2c2876c73d3b1c
|
||||
/agent-note-client-plugin-package-assets-served-by-url/alternatives-considered:
|
||||
en: 98960e10634ee536
|
||||
zh: f0fdb0b0cdf9511c
|
||||
/agent-note-client-plugin-package-assets-served-by-url/consequences:
|
||||
en: 898262f39c0243ae
|
||||
zh: e30f2161f5b669fe
|
||||
/agent-note-client-plugin-package-assets-served-by-url/testing:
|
||||
en: 8b6ce91df7c4c3a5
|
||||
zh: b149b71cef5be761
|
||||
@@ -0,0 +1,92 @@
|
||||
# Agent Note: Client plugin package assets served by URL
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-19-client-plugin-package-assets.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The browser half of a dynamic Client plugin is a lazy-CJS factory built by the [client build preset](../../../../packages/client/tsdown.client.ts) and loaded through the `/plugins` route of the [Client module system](../../../../packages/client/modules/README.md). That route served only `client.js` and package-local chunks, so an image that plugin code referenced had to be embedded in the bundle as a base64 data URL, and an image used by a stylesheet needed a package-level build plugin that rewrote it into the inlined style.
|
||||
|
||||
The embedded bytes were downloaded and parsed with the JavaScript whether or not the image was shown, and base64 adds a third to their size: the onboarding illustrations made the ui-settings-account `client.js` about 3.4 MB. ui-chat carried its own [tsdown plugin](../../../../packages/client/ui-chat/tsdown.config.ts) for one mask image.
|
||||
|
||||
## Decision
|
||||
|
||||
A Client plugin package keeps raw images under `src/client/assets/`. The shared preset emits each referenced image under `lib/assets/` and turns the reference into a synchronous `require.asset(<document-relative URL>)` call on the bundle's module loader; the package publishes the emitted files with `"exports": { "./assets/*": "./lib/assets/*" }` and `lib/assets` in `files`; the client-modules Host serves them at `/plugins/<package>/assets/<emitted path>`.
|
||||
|
||||
### References and file names
|
||||
|
||||
Client code imports an image (`import art from './assets/a.webp'`), and the package's own stylesheets reference one with a relative `url()`. Both compile to a `require.asset(reference)` call whose argument is the same reference; a stylesheet becomes string literals joined with those calls, which run when its injected text is built. The module system answers each call through its `resolveAsset` option, which returns the reference unchanged unless a carrier supplies one. The emitted file name inserts the content revision, the first 12 hexadecimal digits of the SHA-1 digest of the bytes, before the extension, so one URL always names one content:
|
||||
|
||||
```text
|
||||
src/client/assets/running-whale@2x.png
|
||||
lib/assets/running-whale@2x-06738fa29b2f.png
|
||||
plugins/@deepseek-ai/dsh-client-ui-chat/assets/running-whale%402x-06738fa29b2f.png
|
||||
```
|
||||
|
||||
Each path segment is percent-encoded with `encodeURIComponent` and then `!`, `'`, `(`, `)`, and `*`, so the reference is also a valid unquoted CSS `url()`, and the package name is emitted verbatim. The URL is relative to the shell document, like every app-owned route, so it resolves under any mount and from injected `<style>` text. Components need no hook, injected value, or service: the URL is a module constant computed when the module first runs.
|
||||
|
||||
The supported extensions are `.svg`, `.png`, `.webp`, `.jpg`, and `.jpeg`. [`src/assets.ts`](../../../../packages/client/modules/src/assets.ts) in client-modules owns the media-type table, the export key and target, the revision, the emitted name, and the URL, and the preset, the route, and the gates read them from there. The shared Client type root [`scripts/types/client-assets`](../../../../scripts/types/client-assets/index.d.ts), registered in [`tsconfig.base.client.json`](../../../../tsconfig.base.client.json), declares each extension as a module with a string default export; a test keeps it equal to the table.
|
||||
|
||||
### Build
|
||||
|
||||
The `dsh-package-asset` plugin of the dynamic browser face resolves an import against its source location and fails the build when the file's real path is outside the package's `src/client/assets/` (which also rejects another package's or a dependency's image), when `exports["./assets/*"]` is not exactly `./lib/assets/*`, when the extension is unsupported, when a path segment is `node_modules` or contains `%`, `#`, `?`, `\`, or NUL, which the Host route cannot serve, or when the extension is not lowercase, as the import types are. It emits each file once per build and registers it as a watch input. The three CSS pipelines (CSS Modules, global, and `?inline`) rewrite relative `url()` values through a lightningcss visitor in stylesheets inside the package's own `src/`; `data:`, fragment, scheme, `/`-prefixed, and empty URLs stay unchanged, a relative file reference with a fragment or query fails the build, and dependency stylesheets are not rewritten. lightningcss does not call its `Url` visitor inside `image-set()`, so an `Image` visitor rewrites those options with the same rule. The rewrite writes an identifier placeholder that lightningcss never escapes, so a file name with parentheses or quotes still becomes its resolver call. The Node Client face of a dual-face package rejects asset imports. The browser face is the only writer of `lib/assets/`: a one-shot build deletes files it did not emit, and a watch rebuild keeps them, because the Host still serves the previous bundle generation and pages running it request those names.
|
||||
|
||||
### Host route
|
||||
|
||||
`bundleResource` matches `/plugins/<package>/assets/<path>` before the bundle tables, and such a path is answered by the asset route alone. For an active client row, the path is percent-decoded once and `createRequire(<package manifest>).resolve('<package>/assets/<path>')` applies the package's own exports, so unexported paths, `.` and `..` segments, missing files, and directories do not resolve. The media type comes from the resolved file's extension, because Node reads `#` and `?` in the subpath as URL syntax. Any resolution failure, an inactive package, and an unsupported type answer 404; the query selects nothing.
|
||||
|
||||
A served asset streams from disk on every request; the Host keeps neither its bytes nor a hash, and a request the browser cancels mid-stream ends without a warning. It carries the media type, the bundle `cache-control: public, max-age=31536000, immutable`, `etag` equal to the requested path, `x-content-type-options: nosniff`, and, for SVG, `content-security-policy: script-src 'none'`. An `If-None-Match` equal to the tag answers 304 without opening the file; `HEAD` answers without a body. `fetchBundle()` returns the same responses with a streamed body that closes the file when cancelled.
|
||||
|
||||
### WebWorker preview
|
||||
|
||||
The preview page runs its Host in a Worker, and a page request for a reference never reaches it. Before any Client bundle runs, the page asks the Worker for `GET /__assets__`, answered by tunnel glue like `GET /__boot__`: an uncompressed tar of every file under `lib/assets/` of each image package that declares `"./assets/*": "./lib/assets/*"`, named by its unencoded `plugins/<package>/assets/<path>`, which fits the ustar name limits the base image already met. The page encodes each entry name into its reference and turns the entry into a Blob URL with the media type of its extension, and its `resolveAsset` returns that URL, or the reference when the image holds no such file. The base image already carries these files, so no network request is added; each boot still walks the image's packages, archives every asset, and copies each entry into a Blob, enabled or not.
|
||||
|
||||
### Changes and package manifests
|
||||
|
||||
A rebuilt image produces a new file name in a rebuilt `client.js`, which reaches the browser through the existing bundle HMR; assets have no channel of their own. Client-modules reads a package manifest and the bundle metadata on every reconciliation, that is, on each Loader entry construction and disposal, instead of caching the manifest until restart, so an entry that restarts after a reinstall, upgrade, or move publishes its current declaration, client export, and manifest path; a reconciliation that finds the same manifest and bundle metadata leaves the published graph unchanged and reads no bundle bytes.
|
||||
|
||||
### Gates and migration
|
||||
|
||||
[`check-workspace-constraints`](../../../../scripts/check-workspace-constraints.ts) requires `lib/assets` in `files` when the export is declared. [`verify-client-packages`](../../../../scripts/verify-client-packages.ts) requires the exact export target, allows the export only on dynamic Web Client packages, and keeps the export and the package's references (imports, and `url()` in the stylesheets its Client sources import) consistent in both directions. [`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) treats `./assets/*` as a non-code export. ui-settings-account and ui-chat moved to package assets, and ui-chat's tsdown plugin is gone.
|
||||
|
||||
Images that third-party libraries load themselves (PDF.js fonts, cmaps, wasm, and workers), plugin metadata icons, detail-document images, and static-linked packages keep their own delivery.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep data URLs.** No route is needed, but the bytes travel and parse with the JavaScript, and every package that styles with an image writes its own build plugin.
|
||||
|
||||
**A plain URL string with no runtime helper.** The served Web app needs nothing more, but the WebWorker preview page cannot request the URL, and changing it afterwards means copying the files to a static deployment or rewriting strings found in bundle text. A static copy is a build-time snapshot that bypasses the Host's rules and misses packages installed at runtime; finding strings in bundle text guesses at code. A synchronous call keeps the build output explicit and lets each carrier decide the URL.
|
||||
|
||||
**A Client Cordis service such as `ctx.assets.url(path)`.** Current consumers reference fixed files; a runtime lookup also cannot know the content revision without publishing a per-package asset list.
|
||||
|
||||
**Raw files in a package-root `assets/` without the build.** Authors could not preprocess them, so naming and format conversion would stay manual.
|
||||
|
||||
**The revision in a `?rev=` query, verified by the Host.** The Host must read a whole file to decide between 200 and 404, so a first request reads it twice and the Host must cache hashes by file metadata; a revision in the file name lets the Host only resolve and stream.
|
||||
|
||||
**The `client.js` revision as the asset revision.** That revision comes from the written bundle's metadata after the build, too late to appear in the bundle.
|
||||
|
||||
**Images in CSS only through JavaScript style objects.** Masks, backgrounds, and `@supports` fallbacks would move into components, while the URL is known at build time and the same rules rewrite `url()`.
|
||||
|
||||
**One plugin file protocol shared with detail documents.** Detail images read any package version from local, npm, or Git sources, need authentication because they trigger Host network access, and cache by source. Joining them would move authentication, source providers, and cache policy into client-modules; the Plugin Manager and Plugin Market detail routes stay separate until another consumer needs files of other packages.
|
||||
|
||||
**Manifests from Plugin Manager, or a manifest cache with change checks.** Plugin Manager lists bundles, not every client row, and resolves from the profile installation rather than the Loader resolution of the running entry. The failure was timing: a cache that outlived entry restarts. Rereading on each reconciliation and skipping unchanged results removes the cache instead of adding validation to it.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The ui-settings-account `client.js` is 214 KB, and the account and ui-chat bundles contain no image data URLs; an image costs one same-origin request when shown and may appear after layout.
|
||||
- `require.asset` belongs to the bundle factory contract beside `require.async`; a package built outside this repository must emit the call. The WebWorker preview creates a Blob URL for every package asset of its image at boot, enabled or not, and keeps them for the page's lifetime.
|
||||
- The Desktop forwarder rewrites `/plugins/` cache headers to `no-store`, so Desktop reads an asset again on each display.
|
||||
- The Host does not compare file contents with the revision in the name: a file under `lib/assets/` edited in place keeps its immutable cache entries. Only the build writes that directory.
|
||||
- `require.resolve` runs synchronously on each request; a local measurement took about 43 µs for an existing file and 170 µs for a missing one. It reads exports through Node's package configuration cache, so an `./assets/*` export edited in place takes effect after boot HMR expires that package or the Host restarts. The route does not check the resolved file's real path, so a symbolic link under `lib/assets/` that leads outside the package is served.
|
||||
- A package outside this repository must reproduce the emitted-name rule and the `require.asset` call; the Client module system README records them.
|
||||
- A development session leaves earlier asset files under `lib/assets/` until the next one-shot build removes them; that pruning assumes the browser face is the directory's only writer.
|
||||
- An entry restart reads its manifest and stats its bundle; only a changed result reads the bundle and broadcasts a graph frame (about 30 KB for 69 rows). Editing only `package.json`, without an entry restart, still takes effect at the next entry or Host restart.
|
||||
|
||||
## Testing
|
||||
|
||||
- [package-assets.client.spec.ts](../../../../packages/client/modules/tests/package-assets.client.spec.ts) composes the real Web server and compares every answer with `fetchBundle()`: headers including the SVG policy, 304 without opening the file, `HEAD`, 405, multi-chunk streaming, stream closing on cancel, a browser cancellation that logs nothing, and 404 for excluded subpaths, missing exports and files, `..`, `%2e%2e`, encoded `..`, an empty path, malformed escapes, decoded `%` and `#`, unsupported types, directories, and inactive packages.
|
||||
- [assets.client.spec.ts](../../../../packages/client/modules/tests/assets.client.spec.ts) covers media types, the revision, emitted names, and references; [client-asset-types.spec.ts](../../../../scripts/client-asset-types.spec.ts) keeps the type root equal to the table.
|
||||
- [client-bundle-css.spec.ts](../../../../scripts/client-bundle-css.spec.ts) covers emitted names, resolver calls in scripts and stylesheet text, their CommonJS rendering in a complete build, preserved `url()` forms, each build rejection, the Node face guard, one-shot pruning, and watch rebuilds that keep earlier files; [loader.client.spec.ts](../../../../packages/client/modules/tests/loader.client.spec.ts) covers the default and carrier resolvers.
|
||||
- The gate specs reject each invalid manifest or reference form, and [node-half.client.spec.ts](../../../../packages/client/modules/tests/node-half.client.spec.ts) covers declarations, moved exports, and added or removed client declarations after an entry restart, an unchanged restart that publishes nothing and reads no bundle, and a chunk of a package named `@scope/assets`.
|
||||
- [desktop-onboarding.e2e.ts](../../../../apps/web/tests/desktop-onboarding.e2e.ts) and [reasoning-preview.e2e.ts](../../../../apps/web/tests/reasoning-preview.e2e.ts) assert the package-asset URLs and decode the images in a browser; [preview-boot.e2e.ts](../../../../apps/web/tests/preview-boot.e2e.ts) decodes the ui-chat mask the WebWorker preview resolves to a Blob URL, and the runtime's [package-assets.spec.ts](../../../../packages/experimental/webworker-runtime/tests/package-assets.spec.ts) covers the archive and resolver.
|
||||
@@ -0,0 +1,92 @@
|
||||
# Agent Note: Client 插件包资源经 URL 提供
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-19-client-plugin-package-assets.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
动态 Client 插件的浏览器半侧由 [client 打包预设](../../../../packages/client/tsdown.client.ts) 打成 lazy-CJS factory,经 [Client 模块系统](../../../../packages/client/modules/README.zh.md) 的 `/plugins` 路由加载。这条路由过去只提供 `client.js` 和包内分片,所以插件代码引用的图片只能以 base64 data URL 打进 bundle,样式里用到的图片则要靠包级构建插件替换成内联样式。
|
||||
|
||||
不管图片显不显示,这些字节都随 JavaScript 一起下载和解析,base64 还会让体积大三分之一:引导插图使 ui-settings-account 的 `client.js` 达到约 3.4 MB。ui-chat 还为一张遮罩图维护了自己的 [tsdown 插件](../../../../packages/client/ui-chat/tsdown.config.ts)。
|
||||
|
||||
## Decision
|
||||
|
||||
Client 插件包把原始图片放在 `src/client/assets/`。共享预设把每个被引用的图片产出到 `lib/assets/`,并把引用改成对 bundle 模块加载器的同步调用 `require.asset(<文档相对 URL>)`;包通过 `"exports": { "./assets/*": "./lib/assets/*" }` 和 `files` 里的 `lib/assets` 发布这些产物;client-modules 的 Host 在 `/plugins/<包名>/assets/<产出路径>` 提供它们。
|
||||
|
||||
### 引用与文件名
|
||||
|
||||
Client 代码用 import 引用图片(`import art from './assets/a.webp'`),包自己的样式表用相对 `url()` 引用,两者都编译成参数为同一个引用的 `require.asset(reference)` 调用;样式表变成字符串字面量与这些调用的拼接,在构建注入文本时执行。模块系统通过 `resolveAsset` 选项回答每次调用;除非载体提供了自己的实现,否则原样返回引用。产出文件名在扩展名前插入内容 revision,即文件字节 SHA-1 摘要的前 12 位十六进制,因此一个 URL 永远只对应一份内容:
|
||||
|
||||
```text
|
||||
src/client/assets/running-whale@2x.png
|
||||
lib/assets/running-whale@2x-06738fa29b2f.png
|
||||
plugins/@deepseek-ai/dsh-client-ui-chat/assets/running-whale%402x-06738fa29b2f.png
|
||||
```
|
||||
|
||||
路径逐段先用 `encodeURIComponent` 编码,再编码 `!`、`'`、`(`、`)` 和 `*`,因此引用本身也是合法的无引号 CSS `url()`;包名原样输出。URL 和其他应用自有路由一样相对外壳文档,在任意挂载下、以及在注入的 `<style>` 文本中都能正确解析。组件不需要 hook、注入值或 service:URL 就是模块首次运行时算出的常量。
|
||||
|
||||
支持的扩展名是 `.svg`、`.png`、`.webp`、`.jpg` 和 `.jpeg`。client-modules 的 [`src/assets.ts`](../../../../packages/client/modules/src/assets.ts) 统一定义类型表、导出键与目标、revision、产出文件名和 URL,预设、路由和门禁都从这里读取。共享的 Client 类型根 [`scripts/types/client-assets`](../../../../scripts/types/client-assets/index.d.ts) 在 [`tsconfig.base.client.json`](../../../../tsconfig.base.client.json) 中登记,把每个扩展名声明为默认导出字符串的模块;有测试保证它与类型表一致。
|
||||
|
||||
### 构建
|
||||
|
||||
动态浏览器 face 的 `dsh-package-asset` 插件按源码位置解析 import。以下情况构建失败:文件真实路径不在本包 `src/client/assets/` 内(他包或依赖里的图片也因此被拒绝);`exports["./assets/*"]` 不是恰好 `./lib/assets/*`;扩展名不受支持;路径段为 `node_modules` 或含 Host 路由无法提供的 `%`、`#`、`?`、`\` 或 NUL;扩展名不是小写(导入类型只声明小写扩展名)。每个文件每次构建只产出一次,并登记为监听输入。三条 CSS 管线(CSS Modules、全局样式、`?inline`)对本包 `src/` 内的样式表,经 lightningcss visitor 改写相对 `url()`;`data:`、片段、带协议、以 `/` 开头和空的 URL 保持不变,带片段或查询串的相对文件引用会让构建失败,依赖包的样式表不改写。lightningcss 不会在 `image-set()` 内部调用 `Url` visitor,因此由 `Image` visitor 按同样的规则改写这些选项。改写时先写入 lightningcss 从不转义的标识符占位,因此含括号或引号的文件名仍会变成对应的解析调用。双 face 包的 Node Client face 拒绝资源 import。浏览器 face 是 `lib/assets/` 的唯一写入方:一次性构建会删除本次没有产出的文件,watch 重建则保留它们,因为 Host 仍在提供上一代 bundle,运行它的页面会请求那些文件名。
|
||||
|
||||
### Host 路由
|
||||
|
||||
`bundleResource` 先于 bundle 表匹配 `/plugins/<包名>/assets/<路径>`,这类路径只由资源路由回答。对启用中的 client 行,路径解码一次后,`createRequire(<包的 manifest>).resolve('<包名>/assets/<路径>')` 套用包自己的 exports,未导出路径、`.` 和 `..` 段、不存在的文件和目录都解析不出来。媒体类型按解析出的文件扩展名判断,因为 Node 会把子路径中的 `#` 和 `?` 当作 URL 语法。任何解析失败、未启用的包和不支持的类型都返回 404;查询串不参与选择。
|
||||
|
||||
被提供的资源每次请求都从磁盘流式读取,Host 既不保存字节也不保存 hash;浏览器中途取消的请求直接结束,不记 warning。响应带媒体类型、与 bundle 相同的 `cache-control: public, max-age=31536000, immutable`、等于请求路径的 `etag`、`x-content-type-options: nosniff`;SVG 另带 `content-security-policy: script-src 'none'`。`If-None-Match` 等于该 tag 时返回 304,且不打开文件;`HEAD` 不带 body。`fetchBundle()` 返回同样的响应,body 为流,取消时关闭文件。
|
||||
|
||||
### WebWorker 预览
|
||||
|
||||
预览页的 Host 运行在 Worker 中,页面对引用发出的请求到达不了它。在任何 Client bundle 运行之前,页面向 Worker 请求 `GET /__assets__`,它和 `GET /__boot__` 一样由隧道胶水层回答:镜像中每个声明了 `"./assets/*": "./lib/assets/*"` 的包,其 `lib/assets/` 下的所有文件打成一个未压缩的 tar,条目以未编码的 `plugins/<包名>/assets/<路径>` 命名,基础镜像已满足同样的 ustar 名称限制。页面把每个条目名编码为引用,并按扩展名的媒体类型转成 Blob URL,它的 `resolveAsset` 返回这个 URL;镜像里没有对应文件时返回引用本身。基础镜像本来就带着这些文件,所以不增加网络请求;但每次启动仍会遍历镜像中的包、打包全部资源,并把每个条目复制成 Blob,不论包是否启用。
|
||||
|
||||
### 变化与包 manifest
|
||||
|
||||
图片重建后,重建的 `client.js` 带着新文件名,经现有的 bundle HMR 到达浏览器;资源没有单独的通道。client-modules 每次对账(即每次 Loader 条目构造和销毁)都读取包 manifest 和 bundle 元数据,不再把 manifest 缓存到重启,所以重装、升级或移动后重启的条目会发布当前的声明、client 导出和 manifest 路径;读到相同 manifest 和 bundle 元数据的对账不改动已发布的启动图,也不读取 bundle 字节。
|
||||
|
||||
### 门禁与迁移
|
||||
|
||||
[`check-workspace-constraints`](../../../../scripts/check-workspace-constraints.ts) 要求声明导出时 `files` 含 `lib/assets`。[`verify-client-packages`](../../../../scripts/verify-client-packages.ts) 要求导出目标精确匹配,只允许动态 Web Client 包声明该导出,并要求导出与包内引用(import,以及其 Client 源码所 import 的样式表中的 `url()`)双向一致。[`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) 把 `./assets/*` 视为非代码导出。ui-settings-account 和 ui-chat 已迁移到包资源,ui-chat 的 tsdown 插件已删除。
|
||||
|
||||
三方库自己加载的资源(PDF.js 的字体、cmap、wasm 和 worker)、插件元数据图标、详情文档图片,以及 static-linked 包,保留各自的交付方式。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保留 data URL。** 不需要路由,但字节随 JavaScript 一起传输和解析,每个在样式里用图片的包都要自写构建插件。
|
||||
|
||||
**不带运行时 helper 的纯 URL 字符串。** 普通 Web 应用不需要更多,但 WebWorker 预览页无法请求这个 URL,事后改写只能把文件复制到静态部署,或在 bundle 文本里查找字符串替换。静态复制是构建时的快照,绕过了 Host 的规则,也覆盖不到运行时安装的包;在 bundle 文本里找字符串是在猜代码。同步调用让构建产物保持显式,并由各个载体决定 URL。
|
||||
|
||||
**Client Cordis service,例如 `ctx.assets.url(path)`。** 当前使用方都引用固定文件;运行时查找也拿不到内容 revision,除非另外发布每个包的资源清单。
|
||||
|
||||
**原始文件直接放在包根 `assets/`,不经构建。** 作者无法做预处理,命名和格式转换只能手工维护。
|
||||
|
||||
**revision 放在 `?rev=` 查询串,由 Host 校验。** Host 必须读完整个文件才能决定回 200 还是 404,首次请求要读两遍,还要按文件元数据缓存 hash;revision 写进文件名后,Host 只需解析路径并流式发送。
|
||||
|
||||
**用 `client.js` 的 revision 作为资源 revision。** 这个 revision 在构建写出 bundle 后才由文件元数据得出,来不及写进 bundle。
|
||||
|
||||
**CSS 只能经 JavaScript style 对象引用图片。** 遮罩、背景和 `@supports` 降级会被挪进组件,而 URL 在构建时就能确定,同一套规则也能改写 `url()`。
|
||||
|
||||
**与详情文档共用一套插件文件协议。** 详情图片要从本地、npm 或 Git 读取任意包版本,会触发 Host 网络访问因而需要鉴权,缓存方式也随来源而定。合并会把鉴权、来源提供方和缓存策略搬进 client-modules;在出现需要读取其他包文件的新使用方之前,Plugin Manager 和 Plugin Market 的详情路由保持独立。
|
||||
|
||||
**从 Plugin Manager 获取 manifest,或给 manifest 缓存加变化检查。** Plugin Manager 只列出 bundle,不覆盖所有 client 行,而且从 profile 安装目录解析,而不是用运行中条目的 Loader 解析。问题出在时机:缓存活得比条目重启更久。每次对账重新读取并跳过未变化的结果,是删掉缓存,而不是给缓存加校验。
|
||||
|
||||
## Consequences
|
||||
|
||||
- ui-settings-account 的 `client.js` 为 214 KB,账户和 ui-chat 的 bundle 都不再含图片 data URL;图片显示时多一次同源请求,可能晚于布局出现。
|
||||
- `require.asset` 和 `require.async` 一样属于 bundle factory 契约;仓库外构建的包必须输出这个调用。WebWorker 预览在启动时为镜像里所有包资源创建 Blob URL,不论包是否启用,并在页面生命周期内保留。
|
||||
- Desktop 转发层把 `/plugins/` 的缓存头改写为 `no-store`,Desktop 每次显示都会重新读取资源。
|
||||
- Host 不比对文件内容与文件名中的 revision:原地修改 `lib/assets/` 下的文件会保留它的 immutable 缓存。这个目录只由构建写入。
|
||||
- `require.resolve` 在每次请求时同步执行;本机实测文件存在约 43 µs,不存在约 170 µs。它经 Node 的包配置缓存读取 exports,因此原地修改的 `./assets/*` 导出要等 boot HMR 让该包失效或 Host 重启后才生效。路由不检查解析结果的真实路径,所以 `lib/assets/` 下指向包外的符号链接会被提供。
|
||||
- 仓库外的包必须复刻产出文件名规则和 `require.asset` 调用;Client 模块系统 README 记录了它们。
|
||||
- 开发期间 `lib/assets/` 会留下旧的资源文件,直到下一次一次性构建把它们清掉;这种清理的前提是浏览器 face 是该目录的唯一写入方。
|
||||
- 条目重启时会读取它的 manifest 并 stat 它的 bundle;只有结果变化时才读取 bundle 并广播启动图帧(69 行时约 30 KB)。只改 `package.json` 而条目没有重启时,仍要到下次条目重启或 Host 重启才生效。
|
||||
|
||||
## Testing
|
||||
|
||||
- [package-assets.client.spec.ts](../../../../packages/client/modules/tests/package-assets.client.spec.ts) 组合真实 Web server,并将每个响应与 `fetchBundle()` 比对:包括 SVG 策略在内的响应头、不打开文件的 304、`HEAD`、405、多分片流式发送、取消时关闭流、浏览器取消请求时不记日志,以及以下情况的 404:被排除的子路径、缺少导出或文件、`..`、`%2e%2e`、编码后的 `..`、空路径、错误的转义、解码后的 `%` 和 `#`、不支持的类型、目录和未启用的包。
|
||||
- [assets.client.spec.ts](../../../../packages/client/modules/tests/assets.client.spec.ts) 覆盖媒体类型、revision、产出文件名和引用;[client-asset-types.spec.ts](../../../../scripts/client-asset-types.spec.ts) 保证类型根与类型表一致。
|
||||
- [client-bundle-css.spec.ts](../../../../scripts/client-bundle-css.spec.ts) 覆盖产出文件名、脚本和样式表文本中的解析调用、完整构建中它们的 CommonJS 渲染、保留的 `url()` 形式、每种构建拒绝、Node face 守卫、一次性构建的清理,以及 watch 重建保留旧文件;[loader.client.spec.ts](../../../../packages/client/modules/tests/loader.client.spec.ts) 覆盖默认解析和载体解析。
|
||||
- 各门禁的 spec 拒绝每种无效的 manifest 或引用形式;[node-half.client.spec.ts](../../../../packages/client/modules/tests/node-half.client.spec.ts) 覆盖条目重启后的声明更新、导出移动、client 声明的增减、未变化的重启既不发布也不读取 bundle,以及名为 `@scope/assets` 的包的分片。
|
||||
- [desktop-onboarding.e2e.ts](../../../../apps/web/tests/desktop-onboarding.e2e.ts) 和 [reasoning-preview.e2e.ts](../../../../apps/web/tests/reasoning-preview.e2e.ts) 断言包资源 URL,并在浏览器中解码这些图片;[preview-boot.e2e.ts](../../../../apps/web/tests/preview-boot.e2e.ts) 解码 WebWorker 预览解析为 Blob URL 的 ui-chat 遮罩图,运行时的 [package-assets.spec.ts](../../../../packages/experimental/webworker-runtime/tests/package-assets.spec.ts) 覆盖归档和解析。
|
||||
@@ -19,6 +19,7 @@ const MODE = webSnapshotMode()
|
||||
const EXPECTED = fileURLToPath(new URL('./expected/desktop-onboarding', import.meta.url))
|
||||
const NS = 'ui-settings-account'
|
||||
const INITIAL = { version: 1, step: 'welcome', purpose: null, process: null, completion: null, usage: 'compact', developerTools: false }
|
||||
const ACCOUNT_ASSET = /\/plugins\/@deepseek-ai\/dsh-client-ui-settings-account\/assets\/onboarding-[a-z-]+-[0-9a-f]{12}\.(?:svg|webp)$/
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: App-only desktop onboarding', () => {
|
||||
let root: string
|
||||
@@ -124,15 +125,18 @@ describe.skipIf(MODE === 'record')('web e2e: App-only desktop onboarding', () =>
|
||||
async function snapshot(name: string): Promise<void> {
|
||||
await expect.poll(() => page.locator('[data-desktop-onboarding]').getAttribute('aria-busy')).toBe('false')
|
||||
const hasIllustration = name.startsWith('welcome') || name.startsWith('credit')
|
||||
if (hasIllustration) {
|
||||
const decoded = await page.locator('[class*="illustration"] > img').evaluateAll(async elements =>
|
||||
Promise.all(elements.map(async (element) => {
|
||||
const image = element as HTMLImageElement
|
||||
await image.decode()
|
||||
return image.naturalWidth > 0 && image.naturalHeight > 0
|
||||
})))
|
||||
expect(decoded).toEqual([true, true])
|
||||
}
|
||||
// Illustrations and card icons are package assets fetched after the step renders.
|
||||
const images = await page.locator('[data-desktop-onboarding] img').evaluateAll(async elements =>
|
||||
Promise.all(elements.map(async (element) => {
|
||||
const image = element as HTMLImageElement
|
||||
await image.decode()
|
||||
return {
|
||||
source: image.currentSrc, illustration: image.matches('[class*="illustration"] > img'),
|
||||
loaded: image.naturalWidth > 0 && image.naturalHeight > 0,
|
||||
}
|
||||
})))
|
||||
expect(images.filter(image => !image.loaded || !ACCOUNT_ASSET.test(image.source)), `${name}: images outside loaded package assets`).toEqual([])
|
||||
expect(images.filter(image => image.illustration), `${name}: illustration images`).toHaveLength(hasIllustration ? 2 : 0)
|
||||
const aria = await captureStableAria(page, '[data-desktop-onboarding]', scaffold.workspaceCwd)
|
||||
await mkdir(EXPECTED, { recursive: true })
|
||||
await compareOrRefreshGolden(join(EXPECTED, `${name}.expected.md`), aria, MODE)
|
||||
@@ -424,7 +428,8 @@ describe.skipIf(MODE === 'record')('web e2e: App-only desktop onboarding', () =>
|
||||
expect(await page.getByRole('button', { name: 'Next', exact: true })
|
||||
.evaluate(element => getComputedStyle(element).backgroundColor)).toBe('rgb(72, 73, 76)')
|
||||
await snapshot('credit-en-dark')
|
||||
expect(await page.locator('[class*="backIcon"]').evaluate(element => getComputedStyle(element).maskImage)).toContain('data:image/svg+xml')
|
||||
expect(await page.locator('[class*="backIcon"]').evaluate(element => getComputedStyle(element).maskImage))
|
||||
.toMatch(/^url\("[^"]*\/plugins\/@deepseek-ai\/dsh-client-ui-settings-account\/assets\/onboarding-back-[0-9a-f]{12}\.svg"\)$/)
|
||||
await page.getByRole('button', { name: 'Next', exact: true }).click()
|
||||
await page.getByRole('button', { name: 'Got it', exact: true }).click()
|
||||
await page.getByRole('checkbox', { name: 'Coding & development', exact: true }).locator('..').click()
|
||||
|
||||
@@ -611,6 +611,21 @@ async function toggleOptionalBundles(page: Page): Promise<void> {
|
||||
await cards.first().waitFor({ timeout: 30_000 })
|
||||
const listed = await cards.evaluateAll(nodes => nodes.map(node => node.getAttribute('data-plugin-package')))
|
||||
expect(listed.filter(name => name !== null && OPTIONAL_BUNDLES.includes(name)).length).toBeGreaterThan(0)
|
||||
// Package assets resolve from the Worker image: ui-chat's injected mask is a Blob URL that decodes.
|
||||
const mask = await page.evaluate(async () => {
|
||||
const styles = [...document.querySelectorAll('style[data-plugin="@deepseek-ai/dsh-client-ui-chat"]')]
|
||||
.map(style => style.textContent).join('\n')
|
||||
const url = /url\((blob:[^)]+)\)/u.exec(styles)?.[1]
|
||||
const unresolved = styles.includes('plugins/@deepseek-ai/dsh-client-ui-chat/assets/')
|
||||
if (url === undefined) return { url: null, width: 0, unresolved }
|
||||
const image = new Image()
|
||||
image.src = url
|
||||
await image.decode()
|
||||
return { url, width: image.naturalWidth, unresolved }
|
||||
})
|
||||
expect(mask.url).toMatch(/^blob:/u)
|
||||
expect(mask.unresolved).toBe(false)
|
||||
expect(mask.width).toBeGreaterThan(0)
|
||||
const result = await page.evaluate(async () => {
|
||||
type Result<T> = { result: { ok: true; value: T } | { ok: false; error: { message: string } } }
|
||||
const transport = (globalThis as typeof globalThis & {
|
||||
|
||||
@@ -12,6 +12,8 @@ import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, writeCo
|
||||
const SUMMARY = `Next paragraph: ${'inspect the loaded context and pending tools '.repeat(8).trim()}`
|
||||
const DELTAS = ['First paragraph', `\nDetails\n\n\n${SUMMARY}`, '\nMore detail']
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./expected/reasoning-preview/running.expected.md', import.meta.url))
|
||||
/** Computed mask of the animated whale: the ui-chat package asset URL, whose `@` is percent-encoded. */
|
||||
const WHALE_MASK = /^url\("([^"]*\/plugins\/@deepseek-ai\/dsh-client-ui-chat\/assets\/running-whale%402x-[0-9a-f]{12}\.png)"\)$/
|
||||
|
||||
class PausedReasoningAdapter extends LlmAdapter {
|
||||
override async listModels(provider: string) { return [{ provider, id: 'paused', name: `${provider}/paused` }] }
|
||||
@@ -96,14 +98,14 @@ it('shows completed paragraph first lines across blank lines with a right-edge f
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference', forcedColors: 'none' })
|
||||
await expect.poll(() => animatedWhale.isVisible()).toBe(true)
|
||||
expect(await animatedWhale.evaluate(element => getComputedStyle(element).maskMode)).toBe('alpha')
|
||||
const imageSize = await animatedWhale.evaluate(async (element) => {
|
||||
const source = getComputedStyle(element).maskImage.match(/^url\("?(data:image\/png;base64,[A-Za-z0-9+/=]+)"?\)$/)?.[1]
|
||||
if (source === undefined) throw new Error('Running whale mask must use the bundled PNG data URL')
|
||||
const imageSize = await animatedWhale.evaluate(async (element, mask) => {
|
||||
const source = getComputedStyle(element).maskImage.match(new RegExp(mask))?.[1]
|
||||
if (source === undefined) throw new Error('Running whale mask must use the ui-chat package asset URL')
|
||||
const image = new Image()
|
||||
image.src = source
|
||||
await image.decode()
|
||||
return [image.naturalWidth, image.naturalHeight]
|
||||
})
|
||||
}, WHALE_MASK.source)
|
||||
expect(imageSize).toEqual([28, 28])
|
||||
expect(await restingWhale.isVisible()).toBe(false)
|
||||
const firstWhaleFrame = await whale.screenshot({ animations: 'allow' })
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
en: 96b95cdb015c1af6
|
||||
zh: 01e4144d91551c13
|
||||
/client-modules/the-scan:
|
||||
en: 91ce66fab388a8a2
|
||||
zh: 77428943582ae3f6
|
||||
en: 811c0e8a3a370399
|
||||
zh: 4a49a3999d35503a
|
||||
/client-modules/the-bundle-route-and-index-injection:
|
||||
en: d653be500d3a0d67
|
||||
zh: fffb1901ce8ba7d9
|
||||
en: 82c089fbd9ffd535
|
||||
zh: 30ac52d01b4fc074
|
||||
/client-modules/the-service:
|
||||
en: 000882ed7f09ad3f
|
||||
zh: c17c05f087d9574d
|
||||
en: b1df1bf2b8ee3ad1
|
||||
zh: 6373d1b8cae22d02
|
||||
@@ -95,11 +95,11 @@ A package joins the table by declaring `dsh.client` (`platform: 'web'`, optional
|
||||
|
||||
Scanning is incremental per package; there is no full-rescan code path. Every cordis `internal/plugin` emission (fiber construction or disposal) marks the fiber's entry name dirty, and a microtask flush reconciles each dirty name against the live loader entries. The activation pass seeds the same dirty set with all current entries and flushes synchronously, so first scan and steady state share one implementation — with opposite failure postures. At activation, a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud `AggregateError` listing every broken package: the fiber FAILS and the boot's fail-loud sweep reports it. In steady state, a broken package logs a warning and must not poison the others.
|
||||
|
||||
Package metadata — including the negative "not a client package" verdict — is cached per Loader specifier and owning-tree base URL until restart. A fiber restart from the same source reuses its row and rev untouched; bundle content changes reach the graph only through `rebuilt()`.
|
||||
Each reconciliation reads the package manifest and the bundle metadata again. A fiber restart from the same source that finds the same declaration and bundle metadata keeps its row and rev and reads no bundle bytes; a changed declaration, client export, or bundle is published by that reconciliation. A bundle rebuilt without an entry restart reaches the graph through `rebuilt()`.
|
||||
|
||||
## The bundle route and index injection
|
||||
|
||||
`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` addresses one generated combo script; a one-resource request uses the same form and is the HMR path. The script is concatenated once on its first `GET` and ends with a `sourceMappingURL` that carries the combo query alone — `??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`, resolved against the script's own directory rather than the document. The map files are not read by startup, index rendering, script `GET`, or `HEAD`; the first map `GET` reads and validates them, composes one Indexed Source Map v3, and caches that body. An authored component map supplies its section; a component without one receives an identity section whose `sourcesContent` is the captured 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. Materialized 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.
|
||||
`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` addresses one generated combo script; a one-resource request uses the same form and is the HMR path. `/plugins/<package>/assets/<path>` serves a package's published images; the [package README](../../packages/client/modules/README.md#package-assets) owns that route. The script is concatenated once on its first `GET` and ends with a `sourceMappingURL` that carries the combo query alone — `??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`, resolved against the script's own directory rather than the document. The map files are not read by startup, index rendering, script `GET`, or `HEAD`; the first map `GET` reads and validates them, composes one Indexed Source Map v3, and caches that body. An authored component map supplies its section; a component without one receives an identity section whose `sourcesContent` is the captured 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. Materialized 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
|
||||
|
||||
@@ -117,7 +117,7 @@ interface ClientArtifactBaseline {
|
||||
}
|
||||
```
|
||||
|
||||
`ClientModuleRegistry` (`ctx.clientModules`, defined in [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts)) exposes reads and the rebuild face; signatures are in the generated [service catalog](#ctxclientmodules--clientmoduleregistry). `graph()` returns the current composed graph (a stable object between changes), `clientPath(id)` returns the bundle's absolute path, and `artifactBaseline(id)` returns the bundle stat values captured before the current snapshot was read. `fetchBundle()` resolves the same lazy response used by the HTTP route. `rebuilt(id)` is the only entry point through which changed bundle content reaches the graph: it derives the revision from filesystem metadata, and only a revision change reads the new bytes, recomposes the graph, and notifies. `onRebuilt` fires per changed bundle with the new revision; `onGraphChanged` fires after any flush that recomposed the graph (row added or removed, or a rebuilt revision change) and is pull-model — listeners re-read `graph()`. Both notification paths contain listener exceptions so one throwing subscriber cannot skip later subscribers or kill whatever triggered the flush.
|
||||
`ClientModuleRegistry` (`ctx.clientModules`, defined in [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts)) exposes reads and the rebuild face; signatures are in the generated [service catalog](#ctxclientmodules--clientmoduleregistry). `graph()` returns the current composed graph (a stable object between changes), `clientPath(id)` returns the bundle's absolute path, and `artifactBaseline(id)` returns the bundle stat values captured before the current snapshot was read. `fetchBundle()` returns the same responses as the HTTP route: the lazy bundle responses, and package assets read on each request. `rebuilt(id)` publishes a bundle rebuilt without an entry restart: it derives the revision from filesystem metadata, and only a revision change reads the new bytes, recomposes the graph, and notifies; an entry restart publishes through reconciliation instead. `onRebuilt` fires per changed bundle with the new revision; `onGraphChanged` fires after any flush that recomposed the graph (row added or removed, or a rebuilt revision change) and is pull-model — listeners re-read `graph()`. Both notification paths contain listener exceptions so one throwing subscriber cannot skip later subscribers or kill whatever triggered the flush.
|
||||
|
||||
[`dsh-client-hmr`](../../packages/client/hmr/README.md) delivers live graph snapshots in the shipped Web composition. The Host forwards existing graph-change notifications immediately, and reconnect sends the current full graph. A graph describes desired browser entries without asserting that Host cleanup has completed. Its artifact poll separately reports rebuilt revisions. Source-map changes alone do not trigger a reload; a new combo-map URL appears only after a bundle revision changes, and each map body is fixed by its first `GET`. Client Modules validates snapshots and serializes reconciliation with those rebuilds; it owns the boot-created entry map, single-resource arrivals, asynchronous removal, unused-module/style cleanup and page-local retry status. Static platform modules and the bootstrap retain their page lifetime; Electron installation is a separate flow.
|
||||
|
||||
@@ -133,7 +133,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
|
||||
|
||||
### `ctx.clientModules` — `ClientModuleRegistry`
|
||||
|
||||
The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index injection rows. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).
|
||||
The web plugin table service: incremental `dsh.client` scan + wire composition + bundle and package-asset route + index injection rows. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
@@ -150,10 +150,13 @@ graph(): WebBootGraph
|
||||
clientPath(id: string): string | undefined
|
||||
|
||||
/**
|
||||
* Serve an advertised revisioned bundle or source map without a Web server.
|
||||
* Unknown URLs return 404, unsupported methods return 405, and `HEAD`
|
||||
* returns the same immutable headers without materializing a body. Each body
|
||||
* is built once on its first `GET`; script construction never reads maps.
|
||||
* Serve an advertised revisioned bundle, source map, chunk, or package asset
|
||||
* without a Web server. Unknown URLs return 404, unsupported methods return
|
||||
* 405, and `HEAD` returns the same immutable headers without materializing a
|
||||
* body. Each bundle body is built once on its first `GET`; script
|
||||
* construction never reads maps. A package asset is read on every request
|
||||
* and answers 304 without a body when `If-None-Match` equals its entity tag;
|
||||
* its body holds the file open until the caller consumes or cancels it.
|
||||
* @param request - shell-carrier request for a `/plugins` resource.
|
||||
* @returns the exact response also exposed by the optional Web route.
|
||||
*/
|
||||
@@ -171,7 +174,7 @@ artifactBaseline(id: string): ClientArtifactBaseline | undefined
|
||||
|
||||
/**
|
||||
* Publish one completed bundle generation (the HMR watch's registration
|
||||
* hook — the only entry point through which build changes reach the graph).
|
||||
* hook; an entry restart publishes through reconciliation instead).
|
||||
* Unchanged mtime, ctime and size preserve the graph without reading the bundle.
|
||||
* @param id - entry id (package name).
|
||||
* @returns the current artifact rev, or undefined for an unknown id.
|
||||
@@ -179,7 +182,8 @@ artifactBaseline(id: string): ClientArtifactBaseline | undefined
|
||||
rebuilt(id: string): string | undefined
|
||||
|
||||
/**
|
||||
* Subscribe to bundle rebuilds; fires only when artifact metadata changes the rev.
|
||||
* Subscribe to bundle rebuilds; fires when {@link rebuilt} changes a rev. A rev an
|
||||
* entry restart publishes reaches only {@link onGraphChanged} listeners.
|
||||
* @param listener - receives the entry id and its new bundle rev.
|
||||
* @returns the unsubscriber.
|
||||
*/
|
||||
|
||||
@@ -95,11 +95,11 @@ interface WebBootGraph {
|
||||
|
||||
扫描是单包增量的;不存在全量重扫代码路径。fiber 构造或 dispose(资源释放)时的每次 cordis `internal/plugin` 发射都把该 fiber 的 entry 名标脏,一次微任务 flush 把每个脏名与实时 loader entry 对账。激活趟以全部当前 entry 灌入同一个脏集合并同步 flush,因此初扫与稳态共享一条实现——但失败姿态相反。激活时,已加载 entry 中的畸形声明或缺失 bundle 会聚合为一个大声的 `AggregateError`,列出每个损坏的包:该 fiber 进入 FAILED,由启动的大声失败 sweep 上报。稳态下,损坏的包只记录一条警告,且不得殃及其他包。
|
||||
|
||||
包元数据——包括「非 client 包」这一否定结论——按 Loader specifier 与所属 tree base URL 缓存至重启。同一来源的 fiber 重启会原样复用其 row 与 rev;bundle 内容变更只经 `rebuilt()` 到达图。
|
||||
每次对账都会重新读取包 manifest 与 bundle 元数据。同一来源的 fiber 重启若读到相同的声明与 bundle 元数据,就保留其 row 与 rev,且不读取 bundle 字节;声明、client 导出或 bundle 发生变化时,由这次对账发布。没有伴随条目重启的 bundle 重建经 `rebuilt()` 到达图。
|
||||
|
||||
## bundle 路由与 index 注入
|
||||
|
||||
`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` 寻址一份生成的 combo 脚本;单资源请求采用同一形式,也是 HMR 路径。脚本在首次 `GET` 时只拼接一次,并以 `sourceMappingURL` 结尾,该引用只携带 combo 查询串——`??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`,按脚本自身目录而非文档解析。启动、index 渲染、脚本 `GET` 和 `HEAD` 都不会读取 map 文件;首次 map `GET` 才会读取并校验这些文件、组合一份 Indexed Source Map v3,并缓存该 body。组件有自带 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 渲染时携带当前图,因此重新加载总是基于实时组合启动。
|
||||
`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` 寻址一份生成的 combo 脚本;单资源请求采用同一形式,也是 HMR 路径。`/plugins/<package>/assets/<path>` 提供包发布的图片,该路由由[包 README](../../packages/client/modules/README.zh.md#package-assets) 说明。脚本在首次 `GET` 时只拼接一次,并以 `sourceMappingURL` 结尾,该引用只携带 combo 查询串——`??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`,按脚本自身目录而非文档解析。启动、index 渲染、脚本 `GET` 和 `HEAD` 都不会读取 map 文件;首次 map `GET` 才会读取并校验这些文件、组合一份 Indexed Source Map v3,并缓存该 body。组件有自带 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 渲染时携带当前图,因此重新加载总是基于实时组合启动。
|
||||
|
||||
## 服务
|
||||
|
||||
@@ -117,7 +117,7 @@ interface ClientArtifactBaseline {
|
||||
}
|
||||
```
|
||||
|
||||
`ClientModuleRegistry`(`ctx.clientModules`,定义于 [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts))暴露读取面与重建面;签名见生成的[服务目录](#ctxclientmodules--clientmoduleregistry)。`graph()` 返回当前组合出的图(两次变更之间是同一个稳定对象),`clientPath(id)` 返回 bundle 的绝对路径,`artifactBaseline(id)` 返回读取当前快照前捕获的 bundle stat 值。`fetchBundle()` 解析 HTTP 路由所使用的同一份惰性响应。`rebuilt(id)` 是变化后的 bundle 内容到达图的唯一入口:它从文件系统元数据派生 revision,只有 revision 变化才会读取新字节、重新组合图并发出通知。`onRebuilt` 按发生变化的 bundle 逐个触发并携带新 revision;`onGraphChanged` 在任何一次重新组合了图的 flush 之后触发(行的增删,或 rebuilt 带来的 revision 变化),并采用拉取模型——监听器自行重读 `graph()`。两条通知路径都会兜住监听器异常,因此一个抛错的订阅者既不能让后续订阅者被跳过,也不能杀死触发这次 flush 的一方。
|
||||
`ClientModuleRegistry`(`ctx.clientModules`,定义于 [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts))暴露读取面与重建面;签名见生成的[服务目录](#ctxclientmodules--clientmoduleregistry)。`graph()` 返回当前组合出的图(两次变更之间是同一个稳定对象),`clientPath(id)` 返回 bundle 的绝对路径,`artifactBaseline(id)` 返回读取当前快照前捕获的 bundle stat 值。`fetchBundle()` 返回与 HTTP 路由相同的响应:惰性的 bundle 响应,以及每次请求都读取的包资源。`rebuilt(id)` 发布没有伴随条目重启的 bundle 重建:它从文件系统元数据派生 revision,只有 revision 变化才会读取新字节、重新组合图并发出通知;条目重启则经对账发布。`onRebuilt` 按发生变化的 bundle 逐个触发并携带新 revision;`onGraphChanged` 在任何一次重新组合了图的 flush 之后触发(行的增删,或 rebuilt 带来的 revision 变化),并采用拉取模型——监听器自行重读 `graph()`。两条通知路径都会兜住监听器异常,因此一个抛错的订阅者既不能让后续订阅者被跳过,也不能杀死触发这次 flush 的一方。
|
||||
|
||||
随包提供的 Web 组合通过 [`dsh-client-hmr`](../../packages/client/hmr/README.zh.md) 交付动态图快照。Host 立即转发现有图变化通知,重连会发送当前完整图。图描述浏览器的目标条目,不声明 Host 清理已经完成。产物轮询另外报告重建 revision。仅 source map 变化不会触发重载;新 combo-map URL 只会在 bundle revision 变化后出现,每份 map body 由其首次 `GET` 固定。Client Modules 校验快照,并将对账与重建串行协调;它持有启动创建的条目映射,负责单资源到达、异步移除、未使用模块与样式清理,以及页面本地重试状态。静态平台模块与 bootstrap 保持页面生命周期;Electron 安装属于独立流程。
|
||||
|
||||
@@ -133,7 +133,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
|
||||
|
||||
### `ctx.clientModules` — `ClientModuleRegistry`
|
||||
|
||||
The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index injection rows. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).
|
||||
The web plugin table service: incremental `dsh.client` scan + wire composition + bundle and package-asset route + index injection rows. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
@@ -150,10 +150,13 @@ graph(): WebBootGraph
|
||||
clientPath(id: string): string | undefined
|
||||
|
||||
/**
|
||||
* Serve an advertised revisioned bundle or source map without a Web server.
|
||||
* Unknown URLs return 404, unsupported methods return 405, and `HEAD`
|
||||
* returns the same immutable headers without materializing a body. Each body
|
||||
* is built once on its first `GET`; script construction never reads maps.
|
||||
* Serve an advertised revisioned bundle, source map, chunk, or package asset
|
||||
* without a Web server. Unknown URLs return 404, unsupported methods return
|
||||
* 405, and `HEAD` returns the same immutable headers without materializing a
|
||||
* body. Each bundle body is built once on its first `GET`; script
|
||||
* construction never reads maps. A package asset is read on every request
|
||||
* and answers 304 without a body when `If-None-Match` equals its entity tag;
|
||||
* its body holds the file open until the caller consumes or cancels it.
|
||||
* @param request - shell-carrier request for a `/plugins` resource.
|
||||
* @returns the exact response also exposed by the optional Web route.
|
||||
*/
|
||||
@@ -171,7 +174,7 @@ artifactBaseline(id: string): ClientArtifactBaseline | undefined
|
||||
|
||||
/**
|
||||
* Publish one completed bundle generation (the HMR watch's registration
|
||||
* hook — the only entry point through which build changes reach the graph).
|
||||
* hook; an entry restart publishes through reconciliation instead).
|
||||
* Unchanged mtime, ctime and size preserve the graph without reading the bundle.
|
||||
* @param id - entry id (package name).
|
||||
* @returns the current artifact rev, or undefined for an unknown id.
|
||||
@@ -179,7 +182,8 @@ artifactBaseline(id: string): ClientArtifactBaseline | undefined
|
||||
rebuilt(id: string): string | undefined
|
||||
|
||||
/**
|
||||
* Subscribe to bundle rebuilds; fires only when artifact metadata changes the rev.
|
||||
* Subscribe to bundle rebuilds; fires when {@link rebuilt} changes a rev. A rev an
|
||||
* entry restart publishes reaches only {@link onGraphChanged} listeners.
|
||||
* @param listener - receives the entry id and its new bundle rev.
|
||||
* @returns the unsubscriber.
|
||||
*/
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
en: dc8c41fbf9869179
|
||||
zh: e45976a585cb9c86
|
||||
/web-ui-style-reference/component-rules:
|
||||
en: 3a35065f2be9de06
|
||||
zh: 41291bba391fb515
|
||||
en: 6715806a6f84d141
|
||||
zh: 94b7a6ede4919512
|
||||
/web-ui-style-reference/corner-radii-and-settings-cards:
|
||||
en: 118972f6c2bd6ce4
|
||||
zh: 85f8416b03e4dd8b
|
||||
|
||||
@@ -20,6 +20,7 @@ Global style sheets belong in `ui-theme/src/styles/`. Component styles live besi
|
||||
- Pair font sizes with line heights and use the theme typography variables when an existing role matches.
|
||||
- Keep source text, terminal output, and diff lines unwrapped when their component contract requires column preservation; use the shared scrollbar styles rather than component-specific scrollbar selectors.
|
||||
- Put presentation in CSS. Inline React styles may pass component-local custom-property values but must not encode theme branches.
|
||||
- A relative `url()` in a plugin stylesheet points to a file in the package's `src/client/assets/`, and the build rewrites it to the [package asset](../packages/client/AGENTS.md#dependency-declaration) URL. `data:`, `#fragment`, `/`-rooted, scheme, and empty `url('')` references stay unchanged, a relative file reference with a fragment or query fails the build, and stylesheets from dependencies are not rewritten.
|
||||
- Preserve keyboard focus visibility and reduced-motion behavior when adding transitions or hover-only controls.
|
||||
- Rounded corners inherit the global superellipse smoothing from ui-theme's `corner-shape.css` on supporting engines. Pair `corner-shape: round` with every full-round `border-radius` (`50%`, `100%`, or a pill radius) so circles and capsules keep circular arcs; the ui-theme corner-shape spec enforces the pairing.
|
||||
- Elevated surfaces (menus, popovers, modals, panels, floating buttons, the composer) set `border: 0` and take `box-shadow: var(--dsw-elevation-panel)`, `var(--dsw-elevation-prominent)`, or the composer's `var(--dsw-elevation-soft)` (larger blur at lower alpha): the 0.5px hairline stroke is the first shadow layer, and `--dsw-elevation-stroke-color` rebinds or suppresses it per surface or state. Never pair a `--dsw-alias-border-*` border with an lv/elevation shadow — the ui-theme elevation spec rejects the pairing; state-colored borders (warn panels) stay real borders.
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
- 字体大小必须与行高配对;已有角色匹配时使用主题排版变量。
|
||||
- 当组件约定要求保留列结构时,源码文本、终端输出和 diff 行不得换行;使用共享滚动条样式,不得定义组件专用滚动条选择器。
|
||||
- 呈现规则写在 CSS 中。React 内联样式可以传递组件局部自定义属性值,但不得编码主题分支。
|
||||
- 插件样式表中的相对 `url()` 指向本包 `src/client/assets/` 中的文件,构建会将其改写为[包资源](../packages/client/AGENTS.md#dependency-declaration) URL。`data:`、`#fragment`、以 `/` 开头、带 scheme 的引用以及空 `url('')` 引用保持不变,带片段或查询串的相对文件引用会让构建失败,依赖包中的样式表不会被改写。
|
||||
- 添加过渡动画或仅悬停可见的控件时,保留清晰可见的键盘焦点和减少动态效果行为。
|
||||
- 支持的引擎上,圆角继承 ui-theme `corner-shape.css` 的全局超级椭圆平滑。每个正圆 `border-radius`(`50%`、`100%` 或胶囊半径)必须配对 `corner-shape: round`,使圆形与胶囊保持圆弧;ui-theme 的 corner-shape spec 强制这一配对。
|
||||
- 高层级表面(菜单、浮层、对话框、面板、悬浮按钮、输入框)设 `border: 0` 并使用 `box-shadow: var(--dsw-elevation-panel)`、`var(--dsw-elevation-prominent)` 或输入框专用的 `var(--dsw-elevation-soft)`(更大模糊、更低透明度):0.5px 发丝描边是第一层投影,`--dsw-elevation-stroke-color` 可按表面或状态重绑或抑制描边。不得将 `--dsw-alias-border-*` border 与 lv/elevation 投影配对——ui-theme 的 elevation spec 会拒绝;状态色 border(warn 面板)保持真 border。
|
||||
|
||||
@@ -70,6 +70,7 @@ Npm sections describe installation and development relationships; each build fac
|
||||
6. **Browser and Node build faces declare externality independently.** A dynamic browser half uses the baseline plus `dsh.client.external`; a statically linked face externalizes every bare specifier; a Node face externalizes its production dependencies ([`tsdown.client.ts`](tsdown.client.ts)). Moving a name between npm sections must not silently change bundle contents.
|
||||
7. **Keep the published payload closed.** Every relative runtime import and emitted asset must be covered by `files`; the repository publint pass checks the exact publication view.
|
||||
8. **Only the root manifest declares React type packages.** `@types/react` and `@types/react-dom` belong only in the root `package.json` `devDependencies`, and `verify-package-dependencies` rejects them in every manifest under `apps/*`, `packages/*/*`, and `vendor/*`; packages still declare the `react` and `react-dom` they import. Each package-local pnpm link is one more location TypeScript scans when it computes module specifiers for every JSX file ([rationale](../../.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.md#1-only-the-root-manifest-declares-react-type-packages)).
|
||||
9. **Package images live under `src/client/assets/`.** Client code references one by relative `import` and the package's own CSS by relative `url()`; supported extensions are `.svg`, `.png`, `.webp`, `.jpg`, and `.jpeg`. The package declares `"./assets/*": "./lib/assets/*"` in `exports` and `lib/assets` in `files`; the build preset emits each referenced file under `lib/assets/`, and the browser loads it through the [client-modules asset route](modules/README.md).
|
||||
|
||||
## Build-time browser environment
|
||||
|
||||
@@ -143,7 +144,7 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore
|
||||
|
||||
Bringing up a new `packages/client/<name>` plugin package (ui-workspace is a complete example; ui-sidebar/ui-user-questions are minimal skeletons):
|
||||
|
||||
1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-<name>`, exports `.`/`./client`/`./src/*`/`./package.json`, `dsh.client` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency), `tsdown.config.ts` (`clientBundle(import.meta.dirname, ['lib/types/index.js'])`), `src/index.ts` (empty node-half apply), `src/css-modules.d.ts` when using CSS Modules, and `README.md` with the Model Experience section.
|
||||
1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-<name>`, exports `.`/`./client`/`./src/*`/`./package.json` plus `./assets/*` for [package images](#dependency-declaration), `dsh.client` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency), `tsdown.config.ts` (`clientBundle(import.meta.dirname, ['lib/types/index.js'])`), `src/index.ts` (empty node-half apply), `src/css-modules.d.ts` when using CSS Modules, and `README.md` with the Model Experience section.
|
||||
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dsh.client` row in `packages/bundle/web-app/cordis.patch.yml`; a `packages/bundle/web-app/package.json` dependency (profile boots resolve bare row names through the computed runtime resolution, which is installed as an interception on Node's resolvers — a row whose package no manifest declares fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
|
||||
3. **dsh.client manifest semantics**: `platform: 'web'` always, and the declaration requires a `./client` export (the scan throws without one); `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is Cordis fiber inject waiting on *services*, nothing else. A non-baseline `external` request sequences its dynamic supplier ahead of the consumer — see [shared modules](#shared-modules-and-the-module-graph).
|
||||
4. **Registering into another package's slot**: apply order is unconstrained, and a business service is not a declaration barrier. Use `ctx.slots.inject(name, () => ctx.slots.register(...))`; it waits on the actual declaration, removes the contribution when that declaration collapses, reruns after redeclaration, and leaves with the caller's plugin fiber. Return a generator yielding each registration when several contributions must install and roll back atomically. A bare `slots.register` into an undeclared slot remains an error; keep service edges only for services the contribution actually reads.
|
||||
|
||||
@@ -98,6 +98,12 @@ export interface ClientTransportHooks {
|
||||
* bundles load over HTTP.
|
||||
*/
|
||||
loadBundle?(url: string): Promise<void>
|
||||
/**
|
||||
* Package-asset resolver for the module system, present when the page cannot
|
||||
* request `plugins/<package>/assets/<path>` itself (the worker tunnel): it
|
||||
* maps each reference to a URL the page can load. Absent in the served web app.
|
||||
*/
|
||||
resolveAsset?(reference: string): string
|
||||
/**
|
||||
* The transport owner declares the page owns the Host outright: the Host
|
||||
* runs inside a worker this page spawned, so no other party can reach it and
|
||||
|
||||
@@ -18,8 +18,11 @@
|
||||
en: 25f536f156ec0bcc
|
||||
zh: a522355b3e0edeae
|
||||
/deepseek-ai-dsh-client-modules/use-this-package/declaring-a-client-plugin:
|
||||
en: d4a7f865f4593724
|
||||
zh: 4313eb8fe9cd5c98
|
||||
en: cc0eb2c35a7a0998
|
||||
zh: 0e1df23869410c7a
|
||||
/deepseek-ai-dsh-client-modules/use-this-package/package-assets:
|
||||
en: 529af1eb8bf1acf2
|
||||
zh: 7d891a5dfa90c625
|
||||
/deepseek-ai-dsh-client-modules/use-this-package/what-the-browser-loads:
|
||||
en: 1383d68472b3c057
|
||||
zh: 94dd7d9abbdbccf3
|
||||
@@ -45,8 +48,8 @@
|
||||
en: 6d16b74acb333e3b
|
||||
zh: fcb3a20cc8c39e4a
|
||||
/deepseek-ai-dsh-client-modules/understand-the-implementation/incremental-composition:
|
||||
en: 41c38d220c99e49e
|
||||
zh: 4501a507fb1d97d5
|
||||
en: d2c42ee89ca84f7b
|
||||
zh: 70ee9dd5e6aa5327
|
||||
/deepseek-ai-dsh-client-modules/understand-the-implementation/boot-manifest-injection:
|
||||
en: 253d9f265cd83ed0
|
||||
zh: 9d0f8df84d9d3ac9
|
||||
@@ -54,8 +57,8 @@
|
||||
en: df2994f9ff8872c9
|
||||
zh: cd776526ed2b51e6
|
||||
/deepseek-ai-dsh-client-modules/understand-the-implementation/source-map:
|
||||
en: fabe9db8c44e2d90
|
||||
zh: 89ca94a8161f57ed
|
||||
en: d8b5407a0279a3f9
|
||||
zh: 27af3437fad78672
|
||||
/deepseek-ai-dsh-client-modules/further-exploration:
|
||||
en: 7e5b283efe4f1d16
|
||||
zh: 79472b64a0d759fe
|
||||
@@ -66,8 +69,8 @@
|
||||
en: ca75c51c89c2c9b0
|
||||
zh: de54f3467ef3b9be
|
||||
/deepseek-ai-dsh-client-modules/known-limitations-and-deferred-work:
|
||||
en: 6dbb155bc65c9d65
|
||||
zh: 368b553a56e46dfc
|
||||
en: 97127ddc35daf6d4
|
||||
zh: 1417b06046b01067
|
||||
/deepseek-ai-dsh-client-modules/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: 3bb0c20c93f16f98
|
||||
@@ -33,6 +33,11 @@ Use it when you compose or build a browser client plugin: the package turns a pa
|
||||
|
||||
A browser plugin package declares `dsh.client` in its `package.json` with `platform: 'web'`, exports a `./client` bundle, and lists any non-baseline module requests under `dsh.client.external`. The host half turns each declaration into a served bundle under `/plugins`, ordered so dynamic providers load before their consumers.
|
||||
|
||||
<a id="package-assets"></a>
|
||||
### Package assets
|
||||
|
||||
The same `/plugins` route serves the images a client package publishes through its `"./assets/*": "./lib/assets/*"` export. The build emits each referenced asset with its content revision, the first 12 hexadecimal digits of the SHA-1 digest of its bytes, inserted before the extension, and compiles the import, or the stylesheet `url()`, into a synchronous `require.asset("plugins/<package>/assets/<emitted path>")` call on the bundle's module loader, so one URL always names one content. Each path segment is percent-encoded with `encodeURIComponent` and then `!`, `'`, `(`, `)`, and `*`, so the reference is also a valid unquoted CSS `url()`; a package built outside this repository must emit the same form. The call returns the document-relative reference unless the page's carrier supplies `resolveAsset`: the WebWorker preview, whose page cannot request the reference, maps it to a Blob URL of the bytes its Worker image holds. Any `/plugins/<package>/assets/` path is answered by the asset route alone: for an active client row, the Host percent-decodes the path once and resolves `<package>/assets/<path>` with Node's `require.resolve` from the package's own manifest, so the package's exports decide which files exist. A path that does not resolve, a resolved file whose extension is not `.svg`, `.png`, `.webp`, `.jpg`, or `.jpeg`, a malformed escape, and an inactive package answer 404; the query selects nothing. A served asset streams from disk on every request without caching and carries its media type, the bundle `cache-control: public, max-age=31536000, immutable`, `etag: "<requested path>"`, and `x-content-type-options: nosniff`; an SVG additionally carries `content-security-policy: script-src 'none'`, so an SVG opened directly runs no script. An `If-None-Match` equal to that tag answers 304 without opening the file, and `HEAD` answers without a body. `fetchBundle()` returns the same responses, with a streamed body that closes the file when cancelled.
|
||||
|
||||
### What the browser loads
|
||||
|
||||
The application combo scripts carry only each plugin's `client.js` entry and register those factories once during boot; module bodies remain lazy and run only at first import or materialization. A source `import()` split by tsdown compiles to `require.async("./client.<name>.js")`; its versioned sibling script arrives only when that expression runs. Rows that share a combo URL share one in-flight script task. A combo whose `<script>` fails to load is requested once more; a combo that loads without registering a row is never re-executed, because the batch script registers its packages in sequence and a replay would stop at the first duplicate registration. In both cases each still-missing row then loads its own one-resource combo URL, so one failed batch costs at most three requests per missing row and leaves the rows it did register untouched. The module system records the last import failure per row (transport, registration, dependency cascade, or factory execution); the Web boot audit reports that text per entry. HMR switches one changed row to its revisioned one-resource combo URL. `<id>/client` and the bare id resolve to the same exports, because a plugin bundle is its package's client half.
|
||||
@@ -73,7 +78,7 @@ Executing a plugin bundle only registers its factory; every module-body side eff
|
||||
|
||||
### Incremental composition
|
||||
|
||||
The Node half scans incrementally per package — no full-rescan path. Every `internal/plugin` emission marks the fiber's entry name dirty; a microtask flush reconciles each dirty name against the live loader entries, and the activation pass seeds the same dirty set and flushes synchronously, so first scan and steady state share one implementation. Package metadata is cached per Loader specifier and owning-tree base URL until restart, while the resolved manifest package name identifies the browser module. Distinct active Loader sources resolving to one package name are rejected; removing the conflict promotes the remaining source without requiring its fiber to restart. Bundle content changes reach the graph only through `rebuilt()` (the HMR hook).
|
||||
The Node half scans incrementally per package — no full-rescan path. Every `internal/plugin` emission marks the fiber's entry name dirty; a microtask flush reconciles each dirty name against the live loader entries, and the activation pass seeds the same dirty set and flushes synchronously, so first scan and steady state share one implementation. Each reconciliation reads the package manifest and the bundle metadata again, so an entry that restarts after its package was reinstalled, upgraded, or moved publishes its current `dsh.client` declaration and client export; a reconciliation that finds the same manifest and bundle metadata publishes nothing and reads no bundle bytes, and an unchanged bundle keeps its revision, so the browser keeps the loaded plugin. The resolved manifest package name identifies the browser module. Distinct active Loader sources resolving to one package name are rejected; removing the conflict promotes the remaining source without requiring its fiber to restart. A bundle rebuilt without an entry restart reaches the graph through `rebuilt()` (the HMR hook).
|
||||
|
||||
The Node half snapshots each `client.js` entry before publication and creates combo descriptors without building response bodies. It groups resources into combo route keys (`/plugins/??...&rev=...`), with one bootstrap combo for the modules row and one or more application combos for the other rows; each phase is partitioned before a URL exceeds 3 KiB. The graph and batch descriptors carry the app-directory-relative form of the same key (`plugins/??...&rev=...`), which a browser document resolves under its own mount, while the response table stays keyed by the absolute route; the source-map trailer is resolved against the combo script's own directory. A script body is combined once on its first `GET` and ends with its map reference. The corresponding map files are read, validated, and combined separately on the first map `GET`; `HEAD` materializes neither body. The Host does not scan or preload sibling chunks: an exact `/plugins/<package>/client.<name>.js?rev=<rev>` request reads that script through the asynchronous file API, so a filesystem that materializes files on first asynchronous read can supply it, and caches it; its map remains uncomputed until the map URL is requested. The chunk reference the browser derives from its owner row is document-relative as well (`plugins/<package>/client.<name>.js?rev=<rev>`), and a chunk script's map trailer is the bare `client.<name>.js.map?rev=<rev>` name, which resolves against the script's own directory. Every combo or chunk map is Indexed Source Map v3 and uses an authored section when available or an identity section for the packaged bundle. Initial publication and HMR derive per-plugin revisions from the entry's `mtimeMs`, `ctimeMs`, and size, without hashing artifact contents. Status-change time distinguishes rewrites that preserve mtime and size. Unchanged artifacts therefore retain their revisions across Host restarts, so SSE reconnection does not replace their browser plugins. The shared preset stamps `client.js` after every package output is written, so a chunk-only rebuild changes the owner revision without a Host-side chunk scan. Combo revisions derive from the ordered row revisions. Advertised combo responses and requested chunk responses remain immutable across unrelated graph recomposition, and an unknown resource or revision returns 404.
|
||||
|
||||
@@ -92,6 +97,7 @@ The host contributes structured index rows that inject, into `<head>`: the `wind
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | Node half: `ClientModuleRegistry`, scan, artifact snapshots, optional combo route, structured index rows |
|
||||
| [`src/assets.ts`](src/assets.ts) | Package-asset media types, export, content revision, revisioned file name, and browser reference shared by the build preset, the asset route, and gates |
|
||||
| [`src/client/index.ts`](src/client/index.ts) | Browser half: bootstrap export, `ctx.modules` enrollment |
|
||||
| [`src/client/system.ts`](src/client/system.ts) | `ClientModuleSystem`: load/materialize/invalidate machinery |
|
||||
| [`src/client/entries.ts`](src/client/entries.ts) | Page entry reconciliation, retries and code replacement |
|
||||
@@ -138,6 +144,8 @@ These limits define what the module system does not do. They are current package
|
||||
- **Bootstrap and code replacement limits** — the page retains its modules bootstrap and static platform identities. Removing or replacing the bootstrap requires a page reload; live replacement requests report a page-local error while retaining its fiber and exports; replacing package code and all existing consumers is outside ordinary enable/disable synchronization.
|
||||
- **Lazy delivery retains requested bodies** — the Host holds each bundle and lazy response plan; a script or map body remains cached after its first `GET`, and HMR additionally retains one prior startup generation. Memory grows only for response bodies that clients request while preserving one-generation race tolerance.
|
||||
- **An unrequested prior-generation map reads the current map file** — combo revisions track executable bundles, not debug artifacts. If HMR rebuilds a map before the retained prior URL receives its first map `GET`, that response uses the current authored map with the prior bundle offsets; requesting the map before the rebuild fixes that URL's response.
|
||||
- **A manifest edit alone is not observed** — the Host reads a package manifest when its Loader entry starts or restarts; editing only `package.json` takes effect at the next entry restart or Host restart. The asset route resolves through Node's package configuration cache, so a `./assets/*` export edited in place takes effect only after boot HMR expires that package or the Host restarts.
|
||||
- **Desktop re-reads package assets on every display** — the Desktop forwarder rewrites every `/plugins/` cache header to `no-store`, so the immutable asset headers do not reach its browser cache.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
@@ -33,6 +33,11 @@ kind: "package-reference"
|
||||
|
||||
浏览器插件包在其 `package.json` 中以 `platform: 'web'` 声明 `dsh.client`,导出 `./client` bundle,并在 `dsh.client.external` 下列出任何基座之外的模块请求。宿主半侧把每份声明变成 `/plugins` 下提供的 bundle,并让动态提供方先于其消费方加载。
|
||||
|
||||
<a id="package-assets"></a>
|
||||
### 包资源
|
||||
|
||||
同一个 `/plugins` 路由也提供客户端包通过其 `"./assets/*": "./lib/assets/*"` 导出发布的图片。构建会产出每个被引用的资源,在扩展名之前插入其内容 revision(资源字节 SHA-1 摘要的前 12 位十六进制数字),并把 import 或样式表 `url()` 编译成对 bundle 模块加载器的同步调用 `require.asset("plugins/<package>/assets/<emitted path>")`,因此一个 URL 始终对应一份内容。每个路径段先用 `encodeURIComponent` 编码,再编码 `!`、`'`、`(`、`)` 和 `*`,因此引用本身也是合法的无引号 CSS `url()`;仓库外构建的包必须输出同样的形式。除非页面的载体提供了 `resolveAsset`,这个调用都返回文档相对引用;WebWorker 预览的页面无法请求该引用,它把引用映射为 Worker 镜像中对应字节的 Blob URL。任何 `/plugins/<package>/assets/` 路径都只由资源路由响应:对于活动的客户端 row,Host 对路径做一次百分号解码,并从该包自身的 manifest(元数据清单)出发,用 Node 的 `require.resolve` 解析 `<package>/assets/<path>`,因此由包的导出决定哪些文件存在。无法解析的路径、扩展名不是 `.svg`、`.png`、`.webp`、`.jpg` 或 `.jpeg` 的已解析文件、格式错误的转义以及未激活的包都返回 404;查询串不参与选择。提供的资源每次请求都从磁盘流式读取且不缓存,并携带其媒体类型、与 bundle 相同的 `cache-control: public, max-age=31536000, immutable`、`etag: "<requested path>"` 和 `x-content-type-options: nosniff`;SVG 还额外携带 `content-security-policy: script-src 'none'`,因此直接打开的 SVG 不会运行脚本。`If-None-Match` 等于该 etag 时返回 304 且不打开文件,`HEAD` 的响应不带 body。`fetchBundle()` 返回相同的响应,其流式 body 在取消时关闭文件。
|
||||
|
||||
### 浏览器加载什么
|
||||
|
||||
application combo 脚本只携带每个插件的 `client.js` 入口,并在启动时仅注册一次这些 factory;模块主体仍保持惰性,只在首次 import 或物化时运行。经 tsdown 拆分的源码 `import()` 会编译为 `require.async("./client.<name>.js")`;只有执行该表达式时,对应的带版本同级脚本才会到达。共享 combo URL 的 row 共用一个进行中的脚本任务。`<script>` 加载失败的 combo 会再请求一次;加载成功但没有注册某条 row 的 combo 绝不会重新执行,因为批量脚本按顺序注册各个包,重放会在第一个重复注册处停止。两种情况下,每条仍缺失的 row 随后加载自己的单资源 combo URL,因此一个失败的 batch 对每条缺失 row 最多花费三次请求,且不影响它已经注册的 row。模块系统按 row 记录最后一次 import 失败(传输、注册、依赖级联或 factory 执行);Web 启动审计按条目报告该文本。HMR(热模块替换)会让一条发生变化的 row 改用带 revision 的单资源 combo URL。`<id>/client` 与裸 id 解析到同一组导出,因为插件 bundle 就是其包的客户端半侧。
|
||||
@@ -74,7 +79,7 @@ application combo 脚本只携带每个插件的 `client.js` 入口,并在启
|
||||
|
||||
### 增量组合
|
||||
|
||||
Node 半侧逐包增量扫描——没有全量重扫路径。每次发出 `internal/plugin` 事件时,系统都会把该 fiber 的 entry 名标脏;微任务 flush 会把每个脏名与当前 loader 条目对账,激活 pass 会初始化同一个脏集合并同步 flush,因此首次扫描与稳态共用同一实现。包元数据按 Loader specifier 与所属 tree base URL 缓存至重启,解析出的 manifest(元数据清单)包名作为浏览器模块身份。若不同的 active Loader source 解析到同一包名,组合会失败;移除冲突来源后,剩余来源无需重启 fiber 即可接替。bundle 内容变更只能通过 `rebuilt()`(HMR 钩子)进入图。
|
||||
Node 半侧逐包增量扫描——没有全量重扫路径。每次发出 `internal/plugin` 事件时,系统都会把该 fiber 的 entry 名标脏;微任务 flush 会把每个脏名与当前 loader 条目对账,激活 pass 会初始化同一个脏集合并同步 flush,因此首次扫描与稳态共用同一实现。每次对账都会重新读取包 manifest 与 bundle 元数据,因此包在重新安装、升级或移动之后重启的条目会发布其当前的 `dsh.client` 声明与客户端导出;读到相同 manifest 与 bundle 元数据的对账不发布任何内容,也不读取 bundle 字节;未变化的 bundle 保留其 revision,因此浏览器会保留已加载的插件。解析出的 manifest 包名作为浏览器模块身份。若不同的 active Loader source 解析到同一包名,组合会失败;移除冲突来源后,剩余来源无需重启 fiber 即可接替。没有伴随条目重启的 bundle 重建经 `rebuilt()`(HMR 钩子)进入图。
|
||||
|
||||
Node 半侧会在发布前快照每个 `client.js` 入口,并在不构建响应 body 的情况下创建 combo descriptor。它把资源分组为 combo 路由键(`/plugins/??...&rev=...`):modules row 使用一个 bootstrap combo,其余 row 使用一个或多个 application combo;每个阶段都会在 URL 超过 3 KiB 之前分区。启动图与批次描述符携带同一路由键的应用目录相对形式(`plugins/??...&rev=...`),浏览器文档会按其自身挂载解析它,而响应表仍以绝对路由为键;source-map trailer 则相对该 combo 脚本自身目录解析。脚本 body 在首次 `GET` 时只组合一次,并以对应的 map 引用结尾;map 文件则在首次 map `GET` 时单独读取、校验并组合,`HEAD` 不会物化任一 body。Host 不扫描也不预加载同级 chunk:精确的 `/plugins/<package>/client.<name>.js?rev=<rev>` 请求会通过异步文件 API 读取该脚本并缓存,因此首次异步读取时才物化文件的文件系统也能提供它;其 map 仍会等到 map URL 被请求后才计算。浏览器从所属 row 派生出该 chunk 引用,因此它同样是文档相对形式(`plugins/<package>/client.<name>.js?rev=<rev>`);chunk 脚本携带的 map trailer 则是裸文件名 `client.<name>.js.map?rev=<rev>`,由该脚本自身目录解析。每个 combo 或 chunk map 都是 Indexed Source Map v3,并在可用时使用作者提供的 section,否则为已打包 bundle 生成 identity section。首次发布与 HMR 都从入口的 `mtimeMs`、`ctimeMs` 和大小派生逐插件 revision,不对产物内容求哈希。状态变更时间用于区分保留 mtime 和大小的重写。未变化的产物因此会跨 Host 重启保持 revision,SSE 重连不会替换其浏览器插件。共享预设会在该包所有输出写完后标记 `client.js`,因此仅 chunk 发生重建也会更换 owner revision,无需 Host 扫描 chunk。combo revision 从有序 row revision 派生。已公告的 combo 响应与已请求的 chunk 响应会跨无关图重组保持不可变;未知资源或 revision 返回 404。
|
||||
|
||||
@@ -93,6 +98,7 @@ bundle 路由随注入的 `webServer` 生命周期注册:服务就绪时注册
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | Node 半侧:`ClientModuleRegistry`、扫描、产物快照、可选 combo 路由、结构化 index 行 |
|
||||
| [`src/assets.ts`](src/assets.ts) | 包资源的媒体类型、导出、内容 revision、带 revision 的文件名与浏览器引用,由构建预设、资源路由和门禁共用 |
|
||||
| [`src/client/index.ts`](src/client/index.ts) | 浏览器半侧:bootstrap 导出、`ctx.modules` 登记 |
|
||||
| [`src/client/system.ts`](src/client/system.ts) | `ClientModuleSystem`:加载/物化/失效机制 |
|
||||
| [`src/client/entries.ts`](src/client/entries.ts) | 页面条目对账、重试与代码替换 |
|
||||
@@ -139,6 +145,8 @@ bundle 路由随注入的 `webServer` 生命周期注册:服务就绪时注册
|
||||
- **Bootstrap 与代码替换限制**——页面保留 modules bootstrap 和静态平台模块的身份。移除或替换 bootstrap 需要刷新页面;动态替换请求会报告页面本地错误,并保留其 fiber 与导出;替换包代码及其所有现有消费者不属于普通启停同步。
|
||||
- **惰性提供会保留已请求的 body**——Host 在内存中保留每个 bundle 与惰性响应计划;脚本或 map body 在首次 `GET` 后保留缓存,HMR 还会保留上一代启动响应。内存仅随客户端实际请求的响应 body 增长,同时保留一代竞态容忍。
|
||||
- **从未请求的上一代 map 会读取当前 map 文件**——combo revision 跟踪可执行 bundle,而不跟踪调试产物。若 HMR 在保留的旧 URL 首次收到 map `GET` 前重建 map,该响应会把当前 authored map 与旧 bundle offset 组合;在重建前请求 map 会固定该 URL 的响应。
|
||||
- **仅编辑 manifest 不会被察觉**——Host 在包的 Loader 条目启动或重启时读取包 manifest;只编辑 `package.json` 要到下一次条目重启或 Host 重启时才生效。资源路由经 Node 的包配置缓存解析,因此原地修改的 `./assets/*` 导出要等 boot HMR 让该包的缓存失效、或 Host 重启后才生效。
|
||||
- **Desktop 每次显示都重新读取包资源**——Desktop 转发器把每个 `/plugins/` 缓存头改写为 `no-store`,因此不可变的资源响应头到达不了其浏览器缓存。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Package-asset format shared by the client build preset, the Host asset
|
||||
* route, and repository gates: the supported media types, the export that
|
||||
* publishes built assets, the content revision, and the document-relative
|
||||
* browser reference. Changing any value here changes a published URL or
|
||||
* package-manifest requirement, so the preset, the route, and the gates read
|
||||
* it from this one module.
|
||||
* @module @deepseek-ai/dsh-client-modules/src/assets
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
/** Route segment between a package name and its asset path. */
|
||||
export const PACKAGE_ASSET_SEGMENT = 'assets'
|
||||
|
||||
/** Media type served for each supported asset extension, keyed by the lowercase extension with its dot. */
|
||||
export const PACKAGE_ASSET_MEDIA_TYPES: ReadonlyMap<string, string> = new Map([
|
||||
['.svg', 'image/svg+xml'],
|
||||
['.png', 'image/png'],
|
||||
['.webp', 'image/webp'],
|
||||
['.jpg', 'image/jpeg'],
|
||||
['.jpeg', 'image/jpeg'],
|
||||
])
|
||||
|
||||
/** Package export key that publishes built assets. */
|
||||
export const PACKAGE_ASSET_EXPORT = './assets/*'
|
||||
|
||||
/** The only accepted target of {@link PACKAGE_ASSET_EXPORT}. */
|
||||
export const PACKAGE_ASSET_EXPORT_TARGET = './lib/assets/*'
|
||||
|
||||
/** The `files` entry that publishes {@link PACKAGE_ASSET_EXPORT_TARGET}. */
|
||||
export const PACKAGE_ASSET_FILES_ENTRY = 'lib/assets'
|
||||
|
||||
/** Hex digits kept from the content digest; the same length as client artifact revisions. */
|
||||
const PACKAGE_ASSET_REVISION_LENGTH = 12
|
||||
|
||||
/**
|
||||
* Look up the media type of an asset path.
|
||||
* @param assetPath - file path or URL path; only its final extension is read, case-insensitively.
|
||||
* @returns the served media type, or undefined when the extension is unsupported.
|
||||
*/
|
||||
export function packageAssetMediaType(assetPath: string): string | undefined {
|
||||
const name = assetPath.slice(Math.max(assetPath.lastIndexOf('/'), assetPath.lastIndexOf('\\')) + 1)
|
||||
const dot = name.lastIndexOf('.')
|
||||
return dot <= 0 ? undefined : PACKAGE_ASSET_MEDIA_TYPES.get(name.slice(dot).toLowerCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the content revision of one built asset. The build writes it into
|
||||
* the emitted file name, so a URL names exactly one content and a rebuilt
|
||||
* asset gets a new URL.
|
||||
* @param bytes - exact bytes written under `lib/assets/`.
|
||||
* @returns the first 12 hexadecimal digits of the SHA-1 digest.
|
||||
*/
|
||||
export function packageAssetRevision(bytes: Uint8Array): string {
|
||||
return createHash('sha1').update(bytes).digest('hex').slice(0, PACKAGE_ASSET_REVISION_LENGTH)
|
||||
}
|
||||
|
||||
/**
|
||||
* Name the emitted file of one asset by inserting its revision before the extension.
|
||||
* @param assetPath - `/`-separated path below `src/client/assets/`, such as `icons/whale@2x.png`.
|
||||
* @param rev - {@link packageAssetRevision} of the asset bytes.
|
||||
* @returns the path below `lib/assets/`, such as `icons/whale@2x-<rev>.png`.
|
||||
*/
|
||||
export function packageAssetRevisionedPath(assetPath: string, rev: string): string {
|
||||
const dot = assetPath.lastIndexOf('.')
|
||||
return dot > assetPath.lastIndexOf('/') + 1
|
||||
? `${assetPath.slice(0, dot)}-${rev}${assetPath.slice(dot)}`
|
||||
: `${assetPath}-${rev}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the browser reference to one built asset. The reference is relative
|
||||
* to the shell document, like every other app-owned route, so it resolves
|
||||
* under any document mount and from injected `<style>` text.
|
||||
* @param packageName - owning package name, emitted verbatim; a scoped name keeps its `/`.
|
||||
* @param emittedPath - {@link packageAssetRevisionedPath} result below `lib/assets/`; each segment is
|
||||
* percent-encoded, including `!`, `'`, `(`, `)`, and `*`, so the reference is also a valid unquoted CSS `url()`.
|
||||
* @returns `plugins/<packageName>/assets/<path>`.
|
||||
*/
|
||||
export function packageAssetReference(packageName: string, emittedPath: string): string {
|
||||
const path = emittedPath.split('/')
|
||||
.map(segment => encodeURIComponent(segment).replace(/[!'()*]/gu, char => `%${char.charCodeAt(0).toString(16).toUpperCase()}`))
|
||||
.join('/')
|
||||
return `plugins/${packageName}/${PACKAGE_ASSET_SEGMENT}/${path}`
|
||||
}
|
||||
@@ -48,6 +48,7 @@ export function createClientModuleSystem(
|
||||
registrationTarget: target,
|
||||
bootstrapModule,
|
||||
...(options.loadBundle === undefined ? {} : { loadBundle: options.loadBundle }),
|
||||
...(options.resolveAsset === undefined ? {} : { resolveAsset: options.resolveAsset }),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -351,6 +351,12 @@ export interface ClientBundleRequire {
|
||||
(specifier: string): unknown
|
||||
/** Load and resolve a package-local dynamic chunk asynchronously. */
|
||||
async(specifier: string): Promise<unknown>
|
||||
/**
|
||||
* Resolve a package-asset reference (`plugins/<package>/assets/<path>`) the
|
||||
* build compiled from an asset import or stylesheet `url()` to the URL the
|
||||
* page loads; the reference itself unless the carrier maps it.
|
||||
*/
|
||||
asset(reference: string): string
|
||||
}
|
||||
|
||||
/** One client bundle's factory registration submitted through `window.__ModuleLoader__.load`. */
|
||||
@@ -375,6 +381,8 @@ export interface ClientModuleCreateOptions {
|
||||
staticModules: Record<string, unknown>
|
||||
/** Bundle-load hook. Defaults to a same-origin classic `<script src>` element. */
|
||||
loadBundle?: (url: string) => Promise<void>
|
||||
/** Package-asset reference hook behind {@link ClientBundleRequire.asset}. Defaults to returning the reference. */
|
||||
resolveAsset?: (reference: string) => string
|
||||
}
|
||||
|
||||
/** The modules bundle after its factory has been materialized by the HTML bootstrap facade. */
|
||||
@@ -485,4 +493,6 @@ export interface ClientModuleSystemOptions {
|
||||
bootstrapModule: ClientBootstrapModule
|
||||
/** Bundle-load hook. Defaults to a same-origin classic `<script src>` element. */
|
||||
loadBundle?: (url: string) => Promise<void>
|
||||
/** Package-asset reference hook behind {@link ClientBundleRequire.asset}. Defaults to returning the reference. */
|
||||
resolveAsset?: (reference: string) => string
|
||||
}
|
||||
@@ -111,6 +111,7 @@ export class ClientModuleSystem implements ClientModuleLoader {
|
||||
private readonly materializing = new Set<string>()
|
||||
private readonly graphRows = new Map<string, BootModuleRow>()
|
||||
private readonly loadBundle: (url: string) => Promise<void>
|
||||
private readonly resolveAsset: (reference: string) => string
|
||||
/** Last import or prefetch failure per graph row, cleared by a later success or invalidation. */
|
||||
private readonly importErrors = new Map<string, Error>()
|
||||
/** Batch URLs whose transport or execution already failed; rows still missing from them go straight to their one-resource URL. */
|
||||
@@ -134,6 +135,7 @@ export class ClientModuleSystem implements ClientModuleLoader {
|
||||
})
|
||||
this.seed = new Map(Object.entries(options.staticModules))
|
||||
this.loadBundle = options.loadBundle ?? defaultLoadBundle
|
||||
this.resolveAsset = options.resolveAsset ?? (reference => reference)
|
||||
|
||||
for (const row of options.manifest.modules) {
|
||||
this.graphRows.set(row.id, row)
|
||||
@@ -321,7 +323,7 @@ export class ClientModuleSystem implements ClientModuleLoader {
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the synchronous module-table require and its asynchronous chunk operation. */
|
||||
/** Build the synchronous module-table require, its asynchronous chunk operation, and its asset resolver. */
|
||||
private makeRequire(ownerId: string, edges: Set<string>): ClientBundleRequire {
|
||||
const require = (spec: string): unknown => {
|
||||
edges.add(spec)
|
||||
@@ -335,6 +337,7 @@ export class ClientModuleSystem implements ClientModuleLoader {
|
||||
+ 'and no registered package factory (a build-time externals drift, or a dynamic dependency that did not arrive)',
|
||||
)
|
||||
}
|
||||
require.asset = (reference: string): string => this.resolveAsset(reference)
|
||||
require.async = async (spec: string): Promise<unknown> => {
|
||||
edges.add(spec)
|
||||
if (!spec.startsWith('./')) return await this.import(spec)
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* 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 one-or-more-plugin
|
||||
* combo scripts plus their source maps,
|
||||
* combo scripts plus their source maps, package-local chunks, and the
|
||||
* content-revisioned `./assets/*` exports of active client packages,
|
||||
* contributes the registration facade, application preloads, bootstrap scripts,
|
||||
* and graph to the webserver's index injection table, and provides the
|
||||
* `clientModules` service (the HMR node half's registration/notification
|
||||
@@ -14,26 +15,31 @@
|
||||
* the fiber's entry name dirty; a microtask flush reconciles each dirty name
|
||||
* against the live loader entries. The activation pass seeds the same dirty
|
||||
* set with all current entries and flushes synchronously, so first scan and
|
||||
* steady state share one implementation. Package metadata (including the
|
||||
* negative "not a client package" verdict) is cached per Loader specifier and
|
||||
* owning-tree base URL until restart. The manifest package name identifies
|
||||
* the browser module; distinct active Loader sources for that package are a
|
||||
* composition error. Bundle content changes reach the graph only through
|
||||
* {@link ClientModuleRegistry.rebuilt}.
|
||||
* steady state share one implementation. Each reconciliation reads the
|
||||
* package manifest and the bundle metadata again, so an entry that restarts
|
||||
* after its package was reinstalled or moved publishes them as they are now;
|
||||
* an unchanged result publishes nothing and does not read the bundle. The
|
||||
* manifest package name identifies the browser module; distinct active Loader
|
||||
* sources for that package are a composition error. Bundle rebuilds outside
|
||||
* an entry restart reach the graph through {@link ClientModuleRegistry.rebuilt}.
|
||||
* @module @deepseek-ai/dsh-client-modules
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs'
|
||||
import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import { createRequire } from 'node:module'
|
||||
import { dirname, isAbsolute, join } from 'node:path'
|
||||
import { Readable } from 'node:stream'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { PACKAGE_ASSET_SEGMENT, packageAssetMediaType } from './assets.ts'
|
||||
import { exactPackageSpecifier, parseDshClient, stripClientSuffix } from './client/manifest.ts'
|
||||
import type { WebBootBatch, WebBootBatchPhase, WebBootEntry, WebBootGraph, WebBootRelease } from './client/manifest.ts'
|
||||
|
||||
@@ -69,9 +75,11 @@ export interface ClientArtifactBaseline {
|
||||
readonly size: number
|
||||
}
|
||||
|
||||
/** Resolved metadata cached for one Loader specifier and owning-tree base URL until restart. */
|
||||
/** Package metadata read from the manifest of one active Loader source. */
|
||||
interface PkgMeta extends WebBootRowFields {
|
||||
clientPath: string
|
||||
/** The package's own manifest; package-asset requests resolve against it as a self-reference. */
|
||||
manifestPath: string
|
||||
}
|
||||
|
||||
interface ResolvedPkgMeta {
|
||||
@@ -85,7 +93,7 @@ interface ClientPackageSource extends ResolvedPkgMeta {
|
||||
loaderName: string
|
||||
/** Resolution base of the config tree that owns the row. */
|
||||
baseUrl: string
|
||||
/** Stable cache and contribution key for this source. */
|
||||
/** Stable source identity and contribution key. */
|
||||
sourceKey: string
|
||||
}
|
||||
|
||||
@@ -225,6 +233,28 @@ function artifactRevision(baseline: ClientArtifactBaseline): string {
|
||||
/** Absolute route prefix serving every plugin resource. */
|
||||
const PLUGIN_ROUTE = '/plugins'
|
||||
|
||||
/**
|
||||
* `/plugins/<package>/assets/<path>`. A scoped package name spans two segments
|
||||
* (`@scope/name`); an unscoped name never starts with `@`, so `@scope/assets/…`
|
||||
* stays a chunk request of the package `@scope/assets`.
|
||||
*/
|
||||
const PACKAGE_ASSET_PATH = /^\/plugins\/(@[^/]+\/[^/]+|[^@/][^/]*)\/assets\/(.*)$/
|
||||
|
||||
/** Expose a Node read stream as a shell-carrier Response body; cancelling the body closes the stream. */
|
||||
function transforStreamBody(stream: Readable): ReadableStream<Uint8Array> {
|
||||
const chunks: AsyncIterator<Uint8Array> = stream[Symbol.asyncIterator]()
|
||||
return new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
const next = await chunks.next()
|
||||
if (next.done === true) controller.close()
|
||||
else controller.enqueue(next.value)
|
||||
},
|
||||
async cancel() {
|
||||
await chunks.return?.()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Combo query addressing one ordered plugin-file list. */
|
||||
function comboSearch(ids: readonly string[], rev: string, sourceMap = false): string {
|
||||
const resources = ids.map(id => `${id}/client.js${sourceMap ? '.map' : ''}`).join(',')
|
||||
@@ -595,8 +625,8 @@ window.__ModuleLoader__={
|
||||
|
||||
/**
|
||||
* The web plugin table service: incremental `dsh.client` scan + wire composition
|
||||
* + bundle route + index injection rows. Construction runs the activation scan
|
||||
* synchronously — a malformed declaration or missing bundle among the
|
||||
* + bundle and package-asset route + index injection rows. Construction runs
|
||||
* the activation scan synchronously — a malformed declaration or missing bundle among the
|
||||
* already-loaded entries aggregates into one loud throw (FAILED fiber; the
|
||||
* boot activation audit reports it).
|
||||
*/
|
||||
@@ -610,9 +640,6 @@ export class ClientModuleRegistry extends Service {
|
||||
private readonly release: WebBootRelease
|
||||
private readonly table = new Map<string, WebPluginRecord>()
|
||||
private readonly sources = new Map<string, ClientPackageSource>()
|
||||
// Resolution is entry-local: the same specifier can resolve differently in
|
||||
// separate config trees. Negative verdicts remain stable until restart.
|
||||
private readonly pkgMeta = new Map<string, ResolvedPkgMeta | null>()
|
||||
private readonly rebuildListeners = new Set<(id: string, rev: string) => void>()
|
||||
private readonly graphListeners = new Set<() => void>()
|
||||
private readonly dirty = new Set<string>()
|
||||
@@ -690,16 +717,20 @@ export class ClientModuleRegistry extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve an advertised revisioned bundle or source map without a Web server.
|
||||
* Unknown URLs return 404, unsupported methods return 405, and `HEAD`
|
||||
* returns the same immutable headers without materializing a body. Each body
|
||||
* is built once on its first `GET`; script construction never reads maps.
|
||||
* Serve an advertised revisioned bundle, source map, chunk, or package asset
|
||||
* without a Web server. Unknown URLs return 404, unsupported methods return
|
||||
* 405, and `HEAD` returns the same immutable headers without materializing a
|
||||
* body. Each bundle body is built once on its first `GET`; script
|
||||
* construction never reads maps. A package asset is read on every request
|
||||
* and answers 304 without a body when `If-None-Match` equals its entity tag;
|
||||
* its body holds the file open until the caller consumes or cancels it.
|
||||
* @param request - shell-carrier request for a `/plugins` resource.
|
||||
* @returns the exact response also exposed by the optional Web route.
|
||||
*/
|
||||
async fetchBundle(request: Request): Promise<Response> {
|
||||
const resource = await this.bundleResource(request.method, request.url)
|
||||
const body = resource.body === undefined ? null : Uint8Array.from(resource.body)
|
||||
const resource = await this.bundleResource(request.method, request.url, request.headers.get('if-none-match') ?? undefined)
|
||||
const body = resource.body === undefined ? null
|
||||
: resource.body instanceof Readable ? transforStreamBody(resource.body) : Uint8Array.from(resource.body)
|
||||
return new Response(body, {
|
||||
status: resource.status,
|
||||
...(resource.headers === undefined ? {} : { headers: resource.headers }),
|
||||
@@ -721,7 +752,7 @@ export class ClientModuleRegistry extends Service {
|
||||
|
||||
/**
|
||||
* Publish one completed bundle generation (the HMR watch's registration
|
||||
* hook — the only entry point through which build changes reach the graph).
|
||||
* hook; an entry restart publishes through reconciliation instead).
|
||||
* Unchanged mtime, ctime and size preserve the graph without reading the bundle.
|
||||
* @param id - entry id (package name).
|
||||
* @returns the current artifact rev, or undefined for an unknown id.
|
||||
@@ -751,7 +782,8 @@ export class ClientModuleRegistry extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to bundle rebuilds; fires only when artifact metadata changes the rev.
|
||||
* Subscribe to bundle rebuilds; fires when {@link rebuilt} changes a rev. A rev an
|
||||
* entry restart publishes reaches only {@link onGraphChanged} listeners.
|
||||
* @param listener - receives the entry id and its new bundle rev.
|
||||
* @returns the unsubscriber.
|
||||
*/
|
||||
@@ -842,14 +874,10 @@ export class ClientModuleRegistry extends Service {
|
||||
}
|
||||
|
||||
private resolveMeta(loaderName: string, baseUrl: string): ResolvedPkgMeta | null {
|
||||
const sourceKey = this.sourceKey(loaderName, baseUrl)
|
||||
const cached = this.pkgMeta.get(sourceKey)
|
||||
if (cached !== undefined) return cached
|
||||
const located = this.locatePkgJson(loaderName, baseUrl)
|
||||
if (located === undefined) {
|
||||
// Not a resolvable package root: loader builtins (cordis:include) and
|
||||
// subpath entries (…/gateway) land here — permanently not a client row.
|
||||
this.pkgMeta.set(sourceKey, null)
|
||||
// subpath entries (…/gateway) land here — not a client row.
|
||||
return null
|
||||
}
|
||||
const { packageName, path: pkgPath } = located
|
||||
@@ -859,23 +887,19 @@ export class ClientModuleRegistry extends Service {
|
||||
packageName,
|
||||
dsh !== null && typeof dsh === 'object' ? (dsh as Record<string, unknown>).client : undefined,
|
||||
)
|
||||
if (decl === undefined || decl.platform !== 'web') {
|
||||
this.pkgMeta.set(sourceKey, null)
|
||||
return null
|
||||
}
|
||||
if (decl === undefined || decl.platform !== 'web') return null
|
||||
const clientRel = clientExportOf(packageName, pkg.exports)
|
||||
if (clientRel === undefined) {
|
||||
throw new Error(`client-modules: ${packageName} declares dsh.client but exports no "./client" bundle`)
|
||||
}
|
||||
const meta: PkgMeta = {
|
||||
clientPath: join(dirname(pkgPath), clientRel),
|
||||
manifestPath: pkgPath,
|
||||
...(decl.inject !== undefined ? { inject: decl.inject } : {}),
|
||||
external: decl.external ?? [],
|
||||
immediately: decl.immediately === true,
|
||||
}
|
||||
const resolved = { packageName, meta }
|
||||
this.pkgMeta.set(sourceKey, resolved)
|
||||
return resolved
|
||||
return { packageName, meta }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -920,7 +944,7 @@ export class ClientModuleRegistry extends Service {
|
||||
: internal.resolveSync(loaderName, baseUrl, {}).url
|
||||
} catch {
|
||||
// The Loader cannot resolve the name: its row cannot have imported, so
|
||||
// the name is permanently not a client row.
|
||||
// it is not a client row.
|
||||
return undefined
|
||||
}
|
||||
return this.nearestPackage(moduleUrl, expectedPackageName)
|
||||
@@ -968,20 +992,16 @@ export class ClientModuleRegistry extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the activation-time bundle snapshot.
|
||||
* Run one filesystem read of a declared client bundle.
|
||||
* @param pkgName - package that declares the client bundle.
|
||||
* @param clientPath - absolute path of the built client artifact.
|
||||
* @returns the immutable bytes plus the pre-read filesystem baseline.
|
||||
* @param read - the stat or read of `clientPath`.
|
||||
* @returns the read's result.
|
||||
* @throws {MissingClientBundleError} when the read fails with `ENOENT`; other filesystem errors are rethrown unchanged.
|
||||
*/
|
||||
private initialBundleSnapshot(pkgName: string, clientPath: string): {
|
||||
bundle: Buffer
|
||||
baseline: ClientArtifactBaseline
|
||||
} {
|
||||
private readClientBundle<T>(pkgName: string, clientPath: string, read: () => T): T {
|
||||
try {
|
||||
const baseline = this.captureArtifactBaseline(clientPath)
|
||||
const bundle = readFileSync(clientPath)
|
||||
return { bundle, baseline }
|
||||
return read()
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
throw new MissingClientBundleError(pkgName, clientPath, error)
|
||||
@@ -1054,17 +1074,22 @@ export class ClientModuleRegistry extends Service {
|
||||
}
|
||||
const source = sources[0]
|
||||
if (source === undefined) return this.table.delete(packageName)
|
||||
if (this.table.get(packageName)?.sourceKey === source.sourceKey) return false
|
||||
const { clientPath } = source.meta
|
||||
// Startup and HMR share revisions so unchanged artifacts survive a server restart.
|
||||
const snapshot = this.initialBundleSnapshot(packageName, source.meta.clientPath)
|
||||
const rev = artifactRevision(snapshot.baseline)
|
||||
const baseline = this.readClientBundle(packageName, clientPath, () => this.captureArtifactBaseline(clientPath))
|
||||
const entry = graphRow(packageName, artifactRevision(baseline), source.meta)
|
||||
const current = this.table.get(packageName)
|
||||
// A restart that finds the same manifest and bundle metadata leaves the published graph as it is, unread.
|
||||
const unchanged = current?.sourceKey === source.sourceKey
|
||||
&& isDeepStrictEqual(current.meta, source.meta) && isDeepStrictEqual(current.entry, entry)
|
||||
if (unchanged) return false
|
||||
this.table.set(packageName, {
|
||||
entry: graphRow(packageName, rev, source.meta),
|
||||
entry,
|
||||
loaderName: source.loaderName,
|
||||
sourceKey: source.sourceKey,
|
||||
meta: source.meta,
|
||||
bundle: snapshot.bundle,
|
||||
baseline: snapshot.baseline,
|
||||
bundle: this.readClientBundle(packageName, clientPath, () => readFileSync(clientPath)),
|
||||
baseline,
|
||||
})
|
||||
return true
|
||||
}
|
||||
@@ -1150,13 +1175,71 @@ export class ClientModuleRegistry extends Service {
|
||||
return response
|
||||
}
|
||||
|
||||
private async bundleResource(method: string | undefined, url: string): Promise<{
|
||||
/**
|
||||
* Answer one package-asset request. Node's `require.resolve` applies the
|
||||
* package's `./assets/*` export as a self-reference, so unexported,
|
||||
* `.`/`..`, missing, and directory paths answer 404. The build writes each
|
||||
* asset's content revision into its file name, so a served path names one
|
||||
* content: the body streams from disk on every request, and the path is the
|
||||
* entity tag.
|
||||
* @param method - request method.
|
||||
* @param record - the active row of the addressed package, or undefined when it is not active.
|
||||
* @param assetPath - percent-encoded path below the package's `assets/` route segment.
|
||||
* @param ifNoneMatch - request `If-None-Match` field.
|
||||
* @returns the route answer.
|
||||
*/
|
||||
private assetResponse(
|
||||
method: 'GET' | 'HEAD',
|
||||
record: WebPluginRecord | undefined,
|
||||
assetPath: string,
|
||||
ifNoneMatch: string | undefined,
|
||||
): {
|
||||
status: number
|
||||
headers?: Record<string, string>
|
||||
body?: Buffer
|
||||
body?: Readable
|
||||
} {
|
||||
if (record === undefined || assetPath === '') return { status: 404 }
|
||||
let file: string
|
||||
try {
|
||||
file = createRequire(record.meta.manifestPath)
|
||||
.resolve(`${record.entry.id}/${PACKAGE_ASSET_SEGMENT}/${decodeURIComponent(assetPath)}`)
|
||||
} catch {
|
||||
// Unresolvable or malformed paths name no asset.
|
||||
return { status: 404 }
|
||||
}
|
||||
// The resolved file decides the type: Node reads `#` and `?` in the subpath as URL syntax.
|
||||
const mediaType = packageAssetMediaType(file)
|
||||
if (mediaType === undefined) return { status: 404 }
|
||||
const headers: Record<string, string> = {
|
||||
'content-type': mediaType,
|
||||
'cache-control': IMMUTABLE_CACHE,
|
||||
etag: `"${assetPath}"`,
|
||||
'x-content-type-options': 'nosniff',
|
||||
// A directly opened SVG document runs no script.
|
||||
...(mediaType === 'image/svg+xml' ? { 'content-security-policy': "script-src 'none'" } : {}),
|
||||
}
|
||||
if (ifNoneMatch === headers.etag) return { status: 304, headers }
|
||||
return { status: 200, headers, ...(method === 'HEAD' ? {} : { body: createReadStream(file) }) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer one `/plugins` request. A `/plugins/<package>/assets/` path is
|
||||
* answered by the package-asset route alone; any other path is an advertised
|
||||
* combo or its map, a prior startup generation, or a current-revision
|
||||
* package-local chunk, in that order, and otherwise 404.
|
||||
*/
|
||||
private async bundleResource(method: string | undefined, url: string, ifNoneMatch: string | undefined): Promise<{
|
||||
status: number
|
||||
headers?: Record<string, string>
|
||||
body?: Buffer | Readable
|
||||
}> {
|
||||
if (method !== 'GET' && method !== 'HEAD') return { status: 405 }
|
||||
const requestUrl = new URL(url, 'http://x')
|
||||
const asset = PACKAGE_ASSET_PATH.exec(requestUrl.pathname)
|
||||
if (asset !== null) {
|
||||
const [, packageName = '', assetPath = ''] = asset
|
||||
return this.assetResponse(method, this.table.get(packageName), assetPath, ifNoneMatch)
|
||||
}
|
||||
const resourceUrl = `${requestUrl.pathname}${requestUrl.search}`
|
||||
const response = this.responses.get(resourceUrl)
|
||||
?? this.previousBatchResponses.get(resourceUrl)
|
||||
@@ -1175,9 +1258,18 @@ export class ClientModuleRegistry extends Service {
|
||||
|
||||
private readonly serveBundle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
||||
/* v8 ignore next -- `?? '/'` arm: node:http always sets url on server requests. */
|
||||
const response = await this.bundleResource(req.method, req.url ?? '/')
|
||||
const response = await this.bundleResource(req.method, req.url ?? '/', req.headers['if-none-match'])
|
||||
res.writeHead(response.status, response.headers)
|
||||
res.end(response.body)
|
||||
if (!(response.body instanceof Readable)) {
|
||||
res.end(response.body)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await pipeline(response.body, res)
|
||||
} catch (error) {
|
||||
// The browser cancelled the request, as leaving an onboarding step does; pipeline already closed the file.
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ERR_STREAM_PREMATURE_CLOSE') throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/** Package-asset format: media types, content revisions, revisioned file names, and document-relative references. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
PACKAGE_ASSET_EXPORT,
|
||||
PACKAGE_ASSET_EXPORT_TARGET,
|
||||
PACKAGE_ASSET_FILES_ENTRY,
|
||||
PACKAGE_ASSET_SEGMENT,
|
||||
packageAssetMediaType,
|
||||
packageAssetReference,
|
||||
packageAssetRevision,
|
||||
packageAssetRevisionedPath,
|
||||
} from '../src/assets.ts'
|
||||
|
||||
describe('packageAssetMediaType', () => {
|
||||
it.each([
|
||||
['icon.svg', 'image/svg+xml'],
|
||||
['photo.png', 'image/png'],
|
||||
['nested/photo.webp', 'image/webp'],
|
||||
['photo.jpg', 'image/jpeg'],
|
||||
['photo.jpeg', 'image/jpeg'],
|
||||
['PHOTO.PNG', 'image/png'],
|
||||
['Icon.SvG', 'image/svg+xml'],
|
||||
['archive.txt.png', 'image/png'],
|
||||
['nested\\photo.png', 'image/png'],
|
||||
['C:\\assets\\photo.webp', 'image/webp'],
|
||||
])('serves %s as %s', (assetPath, mediaType) => {
|
||||
expect(packageAssetMediaType(assetPath)).toBe(mediaType)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['photo', 'no extension'],
|
||||
['.png', 'a dotfile'],
|
||||
['nested/.png', 'a nested dotfile'],
|
||||
['photo.png/readme', 'an extension on a parent segment only'],
|
||||
['photo.png\\readme', 'an extension before a backslash separator'],
|
||||
['photo.gif', 'an unsupported extension'],
|
||||
['photo.png.txt', 'an unsupported final extension'],
|
||||
['photo.', 'an empty extension'],
|
||||
])('serves nothing for %s (%s)', (assetPath) => {
|
||||
expect(packageAssetMediaType(assetPath)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('packageAssetRevision', () => {
|
||||
it('keeps the first 12 hexadecimal digits of the SHA-1 content digest', () => {
|
||||
expect(packageAssetRevision(new TextEncoder().encode('abc'))).toBe('a9993e364706')
|
||||
expect(packageAssetRevision(new Uint8Array())).toBe('da39a3ee5e6b')
|
||||
})
|
||||
|
||||
it('changes with the asset bytes', () => {
|
||||
expect(packageAssetRevision(Uint8Array.of(1))).not.toBe(packageAssetRevision(Uint8Array.of(2)))
|
||||
})
|
||||
})
|
||||
|
||||
describe('packageAssetRevisionedPath', () => {
|
||||
it.each([
|
||||
['photo.png', 'photo-a9993e364706.png'],
|
||||
['icons/whale@2x.png', 'icons/whale@2x-a9993e364706.png'],
|
||||
['icons/badge.zh.svg', 'icons/badge.zh-a9993e364706.svg'],
|
||||
['icons.v2/photo', 'icons.v2/photo-a9993e364706'],
|
||||
['icons/.png', 'icons/.png-a9993e364706'],
|
||||
])('inserts the revision before the extension of %s', (assetPath, emitted) => {
|
||||
expect(packageAssetRevisionedPath(assetPath, 'a9993e364706')).toBe(emitted)
|
||||
})
|
||||
})
|
||||
|
||||
describe('packageAssetReference', () => {
|
||||
it('keeps a scoped package name verbatim and encodes each path segment', () => {
|
||||
expect(packageAssetReference('@scope/pkg', 'icons/a b#?%-a9993e364706.svg'))
|
||||
.toBe('plugins/@scope/pkg/assets/icons/a%20b%23%3F%25-a9993e364706.svg')
|
||||
// Characters encodeURIComponent keeps would end an unquoted CSS url().
|
||||
expect(packageAssetReference('plain', "whale(2x)!'*-a9993e364706.png"))
|
||||
.toBe('plugins/plain/assets/whale%282x%29%21%27%2A-a9993e364706.png')
|
||||
})
|
||||
|
||||
it('addresses the document-relative form of the plugin asset route without a query', () => {
|
||||
const reference = packageAssetReference('plain', 'photo-a9993e364706.png')
|
||||
expect(reference).toBe('plugins/plain/assets/photo-a9993e364706.png')
|
||||
expect(reference.startsWith(`plugins/plain/${PACKAGE_ASSET_SEGMENT}/`)).toBe(true)
|
||||
})
|
||||
|
||||
it('pairs the asset export with its only accepted target and published files entry', () => {
|
||||
expect(PACKAGE_ASSET_EXPORT).toBe('./assets/*')
|
||||
expect(PACKAGE_ASSET_EXPORT_TARGET).toBe(`./${PACKAGE_ASSET_FILES_ENTRY}/*`)
|
||||
})
|
||||
})
|
||||
@@ -81,6 +81,7 @@ function bench(
|
||||
transportFailures?: Record<string, number>
|
||||
/** URLs whose script loads but registers only the listed ids (a runtime throw after those registrations). */
|
||||
registerOnly?: Record<string, string[]>
|
||||
resolveAsset?: (reference: string) => string
|
||||
} = {},
|
||||
): Bench {
|
||||
const fetched: string[] = []
|
||||
@@ -144,6 +145,7 @@ function bench(
|
||||
},
|
||||
staticModules: opts.seed ?? {},
|
||||
...(opts.defaultTransport === true ? {} : { loadBundle }),
|
||||
...(opts.resolveAsset === undefined ? {} : { resolveAsset: opts.resolveAsset }),
|
||||
})
|
||||
return { loader, target, fetched, gates }
|
||||
}
|
||||
@@ -249,6 +251,16 @@ describe('lazy CJS arrival', () => {
|
||||
expect(b.fetched).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('resolves a package-asset reference through the carrier, or returns it unchanged without one', async () => {
|
||||
const reference = 'plugins/a/assets/mark-0123456789ab.svg'
|
||||
const factory: Factory = req => ({ url: req.asset(reference) })
|
||||
const served = bench([row('a')], { a: factory })
|
||||
expect(await served.loader.import('a', '', {})).toEqual({ url: reference })
|
||||
|
||||
const mapped = bench([row('a')], { a: factory }, { resolveAsset: asset => `blob:${asset}` })
|
||||
expect(await mapped.loader.import('a', '', {})).toEqual({ url: `blob:${reference}` })
|
||||
})
|
||||
|
||||
it('loads a package-local dynamic chunk only when its factory requests it', async () => {
|
||||
const b = bench([row('a')], {
|
||||
a: req => ({ load: () => req.async('./client.terminal.js') }),
|
||||
|
||||
@@ -224,7 +224,7 @@ async function routeRequest(route: Promise<WebRoute>, url: string, method = 'GET
|
||||
return response
|
||||
},
|
||||
} as unknown as ServerResponse
|
||||
await (await route).handler({ method, url } as IncomingMessage, response)
|
||||
await (await route).handler({ method, url, headers: {} } as IncomingMessage, response)
|
||||
return { status, headers, body }
|
||||
}
|
||||
|
||||
@@ -967,6 +967,20 @@ describe('client bundle activation', () => {
|
||||
expect((await routeRequest(route, url.replace(`rev=${row.rev}`, 'rev=stale'))).status).toBe(404)
|
||||
})
|
||||
|
||||
it('serves a chunk of a package named @scope/assets instead of treating it as an asset request', async () => {
|
||||
const packageName = '@fixture/assets'
|
||||
const clientPath = writePackage(packageName)
|
||||
mkdirSync(dirname(clientPath), { recursive: true })
|
||||
writeFileSync(clientPath, 'module.exports = {}\n')
|
||||
writeFileSync(join(dirname(clientPath), 'client.extra.js'), 'module.exports = { marker: "extra chunk" }\n')
|
||||
const { service, route } = constructWithRoute([packageName])
|
||||
const row = service.graph().entries[0]!
|
||||
|
||||
const chunk = await routeRequest(route, chunkReference(packageName, 'client.extra.js', row.rev))
|
||||
expect(chunk.status).toBe(200)
|
||||
expect(chunk.body.toString('utf8')).toContain('extra chunk')
|
||||
})
|
||||
|
||||
it('reads a package-local chunk and its map body only through the asynchronous filesystem face', async () => {
|
||||
const packageName = '@fixture/async-chunk'
|
||||
const clientPath = writePackage(packageName)
|
||||
@@ -1245,3 +1259,72 @@ describe('module graph order', () => {
|
||||
.toThrow('module graph cycle @fixture/cycle-a -> @fixture/cycle-b -> @fixture/cycle-a')
|
||||
})
|
||||
})
|
||||
|
||||
describe('package manifest refresh', () => {
|
||||
/** Rewrite a fixture package's manifest; its client bundle is left as it is. */
|
||||
const writeManifest = (packageName: string, manifest: Record<string, unknown>): void => {
|
||||
writeFileSync(join(root!, 'node_modules', ...packageName.split('/'), 'package.json'), JSON.stringify({ name: packageName, ...manifest }))
|
||||
}
|
||||
const clientManifest = (client: Record<string, unknown>, clientExport = './lib/client.js'): Record<string, unknown> => ({
|
||||
exports: { './client': clientExport, './package.json': './package.json' },
|
||||
dsh: { client: { platform: 'web', ...client } },
|
||||
})
|
||||
|
||||
it('publishes a reinstalled declaration when its Loader entry restarts, and nothing for an unchanged restart', async () => {
|
||||
const packageName = '@fixture/refresh-declaration'
|
||||
writeBuiltPackage(packageName, { external: ['react'] })
|
||||
const { context, service } = constructWithRoute([packageName])
|
||||
const before = service.graph().entries[0]!
|
||||
const changed = vi.fn()
|
||||
service.onGraphChanged(changed)
|
||||
const warning = vi.spyOn(context.logger, 'warn')
|
||||
// An unchanged restart compares bundle metadata and never reads the bundle.
|
||||
syncRefused.add(service.clientPath(packageName)!)
|
||||
|
||||
emitLoaderEntryChange(context, packageName)
|
||||
await Promise.resolve()
|
||||
expect(changed).not.toHaveBeenCalled()
|
||||
expect(warning).not.toHaveBeenCalled()
|
||||
syncRefused.delete(service.clientPath(packageName)!)
|
||||
|
||||
writeManifest(packageName, clientManifest({ external: ['scheduler'], inject: ['@fixture/slots'] }))
|
||||
emitLoaderEntryChange(context, packageName)
|
||||
await Promise.resolve()
|
||||
expect(changed).toHaveBeenCalledTimes(1)
|
||||
expect(service.graph().entries[0]).toMatchObject({ rev: before.rev, external: ['scheduler'], inject: ['@fixture/slots'] })
|
||||
})
|
||||
|
||||
it('reads a moved client export as a new bundle generation when its Loader entry restarts', async () => {
|
||||
const packageName = '@fixture/refresh-moved'
|
||||
writeBuiltPackage(packageName, {})
|
||||
const { context, service } = constructWithRoute([packageName])
|
||||
const before = service.graph().entries[0]!
|
||||
const moved = join(root!, 'node_modules', '@fixture', 'refresh-moved', 'dist', 'client.js')
|
||||
mkdirSync(dirname(moved), { recursive: true })
|
||||
writeFileSync(moved, 'module.exports = { moved: true }\n')
|
||||
writeManifest(packageName, clientManifest({}, './dist/client.js'))
|
||||
|
||||
emitLoaderEntryChange(context, packageName)
|
||||
await Promise.resolve()
|
||||
expect(service.clientPath(packageName)).toBe(moved)
|
||||
expect(service.graph().entries[0]!.rev).not.toBe(before.rev)
|
||||
})
|
||||
|
||||
it('adds or removes the row when a restarted package gains or loses its client declaration', async () => {
|
||||
const packageName = '@fixture/refresh-membership'
|
||||
writeBuiltPackage(packageName, {})
|
||||
writeManifest(packageName, { exports: { './package.json': './package.json' } })
|
||||
const { context, service } = constructWithRoute([packageName])
|
||||
expect(service.graph().entries).toEqual([])
|
||||
|
||||
writeManifest(packageName, clientManifest({}))
|
||||
emitLoaderEntryChange(context, packageName)
|
||||
await Promise.resolve()
|
||||
expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName])
|
||||
|
||||
writeManifest(packageName, { exports: { './package.json': './package.json' } })
|
||||
emitLoaderEntryChange(context, packageName)
|
||||
await Promise.resolve()
|
||||
expect(service.graph().entries).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,309 @@
|
||||
/** Host package-asset route: exported `./assets/*` files streamed under their revisioned names. */
|
||||
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import type { ReadStream } from 'node:fs'
|
||||
import { request } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { Context, LoggerLevel } from '@deepseek-ai/cordis'
|
||||
import { ModuleLoader } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import WebServer from '@deepseek-ai/dsh-host-webserver'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { packageAssetReference } from '../src/assets.ts'
|
||||
import { ClientModuleRegistry } from '../src/index.ts'
|
||||
|
||||
/** File read streams the route opens, in order, to observe hashing and body streaming. */
|
||||
const opened = vi.hoisted(() => [] as { path: string; stream: ReadStream }[])
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>()
|
||||
return {
|
||||
...actual,
|
||||
createReadStream: (path: Parameters<typeof actual.createReadStream>[0], options?: Parameters<typeof actual.createReadStream>[1]) => {
|
||||
const stream = actual.createReadStream(path, options)
|
||||
opened.push({ path: String(path), stream })
|
||||
return stream
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const ASSETS_ID = '@fixture/assets'
|
||||
const NO_EXPORT_ID = '@fixture/no-export'
|
||||
const INACTIVE_ID = '@fixture/inactive'
|
||||
/** Response headers the asset route owns. */
|
||||
const ASSET_HEADERS = ['content-type', 'cache-control', 'etag', 'x-content-type-options', 'content-security-policy'] as const
|
||||
const IMMUTABLE = 'public, max-age=31536000, immutable'
|
||||
const SVG = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>\n')
|
||||
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1])
|
||||
const WEBP = Buffer.from('RIFF....WEBP')
|
||||
|
||||
let root: string | undefined
|
||||
const contexts: Context[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
|
||||
if (root !== undefined) rmSync(root, { recursive: true, force: true })
|
||||
root = undefined
|
||||
opened.length = 0
|
||||
})
|
||||
|
||||
/** Own one fresh temporary directory per test. */
|
||||
function fixtureRoot(): string {
|
||||
root ??= realpathSync(mkdtempSync(join(tmpdir(), 'dsh-client-assets-')))
|
||||
return root
|
||||
}
|
||||
|
||||
/** Write one file, creating its parent directories. */
|
||||
function writeFile(path: string, contents: string | Buffer): string {
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
writeFileSync(path, contents)
|
||||
return path
|
||||
}
|
||||
|
||||
/** Create a built client package below the fixture's `node_modules`; returns its `lib/assets` directory. */
|
||||
function writeClientPackage(packageName: string, exports: Record<string, string | null>): string {
|
||||
const pkgRoot = join(fixtureRoot(), 'node_modules', ...packageName.split('/'))
|
||||
writeFile(join(pkgRoot, 'package.json'), JSON.stringify({
|
||||
name: packageName,
|
||||
exports: { '.': './lib/index.js', './client': './lib/client.js', './package.json': './package.json', ...exports },
|
||||
dsh: { client: { platform: 'web' } },
|
||||
}))
|
||||
writeFile(join(pkgRoot, 'lib', 'index.js'), 'export default {}\n')
|
||||
writeFile(join(pkgRoot, 'lib', 'client.js'), 'module.exports = {}\n')
|
||||
writeFile(join(pkgRoot, 'secret.png'), PNG)
|
||||
return join(pkgRoot, 'lib', 'assets')
|
||||
}
|
||||
|
||||
/** Create the active, inactive, and export-less packages; returns the active package's `lib/assets`. */
|
||||
function writeFixture(): string {
|
||||
const assets = writeClientPackage(ASSETS_ID, { './assets/private/*': null, './assets/*': './lib/assets/*' })
|
||||
writeFile(join(assets, 'icon.svg'), SVG)
|
||||
writeFile(join(assets, 'photo.png'), PNG)
|
||||
writeFile(join(assets, 'nested', 'a b.webp'), WEBP)
|
||||
// `*` is covered by the reference unit test; Windows cannot create it on disk.
|
||||
writeFile(join(assets, "whale(2x)!'.png"), PNG)
|
||||
writeFile(join(assets, 'notes.txt'), 'text\n')
|
||||
writeFile(join(assets, 'folder.png', 'inner.png'), PNG)
|
||||
writeFile(join(assets, 'private', 'hidden.png'), PNG)
|
||||
// `a#b.png` resolves here because Node reads `#` in the subpath as a fragment.
|
||||
writeFile(join(assets, 'a'), PNG)
|
||||
writeFile(join(writeClientPackage(NO_EXPORT_ID, {}), 'photo.png'), PNG)
|
||||
writeFile(join(writeClientPackage(INACTIVE_ID, { './assets/*': './lib/assets/*' }), 'photo.png'), PNG)
|
||||
return assets
|
||||
}
|
||||
|
||||
/** One route answer reduced to the parts the asset route owns. */
|
||||
interface Answer {
|
||||
status: number
|
||||
headers: Partial<Record<typeof ASSET_HEADERS[number], string>>
|
||||
body: Buffer
|
||||
}
|
||||
|
||||
function assetHeaders(read: (name: string) => string | string[] | null | undefined): Answer['headers'] {
|
||||
const headers: Answer['headers'] = {}
|
||||
for (const name of ASSET_HEADERS) {
|
||||
const value = read(name)
|
||||
if (typeof value === 'string') headers[name] = value
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
interface AssetRequestInit {
|
||||
method?: string
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
/** Composed Host: the modules service and a requester through both carriers. */
|
||||
interface Host {
|
||||
service: ClientModuleRegistry
|
||||
/** Port of the real Web server. */
|
||||
port: number
|
||||
/** Warnings any plugin of the composed Host logged. */
|
||||
warnings: unknown[][]
|
||||
/** Send one raw request target through the Web route and the shell carrier and require identical answers. */
|
||||
both: (path: string, init?: AssetRequestInit) => Promise<Answer>
|
||||
}
|
||||
|
||||
/** Compose the real Web server and the modules service over a Loader double listing the active rows. */
|
||||
async function composeHost(): Promise<Host> {
|
||||
const internal = ModuleLoader.fromInternal()
|
||||
if (internal === undefined) throw new Error('package-asset tests require the Node module loader internals')
|
||||
const context = new Context()
|
||||
contexts.push(context)
|
||||
const warnings: unknown[][] = []
|
||||
context.logger.exporter({ levels: { default: LoggerLevel.WARN }, export: (message) => { if (message.type === 'warn') warnings.push(message.args) } })
|
||||
const baseUrl = pathToFileURL(fixtureRoot()).href + '/'
|
||||
context.baseUrl = baseUrl
|
||||
context.provide('loader', {
|
||||
internal,
|
||||
*entries() {
|
||||
for (const name of [NO_EXPORT_ID, ASSETS_ID]) {
|
||||
yield { options: { name }, fiber: {}, disabled: false, parent: { tree: { ctx: { baseUrl } } } }
|
||||
}
|
||||
},
|
||||
})
|
||||
await context.plugin(WebServer, { host: '127.0.0.1', port: 0 })
|
||||
await context.plugin(ClientModuleRegistry)
|
||||
const service = context.get('clientModules')!
|
||||
const port = context.webServer.port!
|
||||
const http = (path: string, init: AssetRequestInit = {}): Promise<Answer> => new Promise((resolve, reject) => {
|
||||
const outgoing = request({ host: '127.0.0.1', port, path, method: init.method ?? 'GET', headers: init.headers, agent: false }, (res) => {
|
||||
const chunks: Buffer[] = []
|
||||
res.on('data', (chunk: Buffer) => { chunks.push(chunk) })
|
||||
res.on('error', reject)
|
||||
res.on('end', () => {
|
||||
resolve({ status: res.statusCode!, headers: assetHeaders(name => res.headers[name]), body: Buffer.concat(chunks) })
|
||||
})
|
||||
})
|
||||
outgoing.on('error', reject)
|
||||
outgoing.end()
|
||||
})
|
||||
const both = async (path: string, init?: AssetRequestInit): Promise<Answer> => {
|
||||
const answer = await http(path, init)
|
||||
const response = await service.fetchBundle(new Request(`http://localhost${path}`, init))
|
||||
expect({
|
||||
status: response.status,
|
||||
headers: assetHeaders(name => response.headers.get(name)),
|
||||
body: Buffer.from(await response.arrayBuffer()),
|
||||
}).toEqual(answer)
|
||||
return answer
|
||||
}
|
||||
// The Web route registers once the injected webServer is ready.
|
||||
await expect.poll(async () => (await http(assetUrl(ASSETS_ID, 'photo.png'))).status).toBe(200)
|
||||
return { service, port, warnings, both }
|
||||
}
|
||||
|
||||
/** Absolute route the browser requests for the document-relative build reference to an emitted file. */
|
||||
function assetUrl(packageName: string, emittedPath: string): string {
|
||||
return `/${packageAssetReference(packageName, emittedPath)}`
|
||||
}
|
||||
|
||||
describe('package asset route', () => {
|
||||
it('streams exported assets with immutable typed headers and the path as entity tag', async () => {
|
||||
writeFixture()
|
||||
const { both } = await composeHost()
|
||||
|
||||
expect(await both(assetUrl(ASSETS_ID, 'icon.svg'))).toEqual({
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'image/svg+xml',
|
||||
'cache-control': IMMUTABLE,
|
||||
etag: '"icon.svg"',
|
||||
'x-content-type-options': 'nosniff',
|
||||
'content-security-policy': "script-src 'none'",
|
||||
},
|
||||
body: SVG,
|
||||
})
|
||||
expect(await both(assetUrl(ASSETS_ID, 'photo.png'))).toEqual({
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'image/png',
|
||||
'cache-control': IMMUTABLE,
|
||||
etag: '"photo.png"',
|
||||
'x-content-type-options': 'nosniff',
|
||||
},
|
||||
body: PNG,
|
||||
})
|
||||
const nested = await both(assetUrl(ASSETS_ID, 'nested/a b.webp'))
|
||||
expect([nested.status, nested.headers.etag, nested.body]).toEqual([200, '"nested/a%20b.webp"', WEBP])
|
||||
// The reference encodes the characters an unquoted CSS url() rejects; the route decodes them back.
|
||||
const special = await both(assetUrl(ASSETS_ID, "whale(2x)!'.png"))
|
||||
expect([special.status, special.headers.etag, special.body]).toEqual([200, '"whale%282x%29%21%27.png"', PNG])
|
||||
// The revision lives in the emitted name; a query does not select content.
|
||||
expect((await both(`${assetUrl(ASSETS_ID, 'photo.png')}?rev=anything`)).body).toEqual(PNG)
|
||||
})
|
||||
|
||||
it('answers HEAD with the same headers and no body, and rejects other methods', async () => {
|
||||
writeFixture()
|
||||
const { both } = await composeHost()
|
||||
const url = assetUrl(ASSETS_ID, 'icon.svg')
|
||||
const get = await both(url)
|
||||
|
||||
expect(await both(url, { method: 'HEAD' })).toEqual({ ...get, body: Buffer.alloc(0) })
|
||||
expect((await both(url, { method: 'POST' })).status).toBe(405)
|
||||
})
|
||||
|
||||
it('answers 304 without opening the file only when If-None-Match equals the entity tag', async () => {
|
||||
const assets = writeFixture()
|
||||
const { both } = await composeHost()
|
||||
const url = assetUrl(ASSETS_ID, 'icon.svg')
|
||||
const get = await both(url)
|
||||
const opens = opened.filter(entry => entry.path === join(assets, 'icon.svg')).length
|
||||
|
||||
expect(await both(url, { headers: { 'if-none-match': '"icon.svg"' } }))
|
||||
.toEqual({ status: 304, headers: get.headers, body: Buffer.alloc(0) })
|
||||
expect(opened.filter(entry => entry.path === join(assets, 'icon.svg'))).toHaveLength(opens)
|
||||
expect(await both(url, { headers: { 'if-none-match': '"photo.png"' } })).toEqual(get)
|
||||
})
|
||||
|
||||
it('streams an asset spanning several read chunks intact through both carriers', async () => {
|
||||
const assets = writeFixture()
|
||||
// Several default 64 KiB read-stream chunks; small enough for the deep body comparison.
|
||||
const large = Buffer.alloc(160 * 1024, 7)
|
||||
writeFile(join(assets, 'large.png'), large)
|
||||
const { both } = await composeHost()
|
||||
|
||||
const answer = await both(assetUrl(ASSETS_ID, 'large.png'))
|
||||
expect(answer.status).toBe(200)
|
||||
expect(answer.body.equals(large)).toBe(true)
|
||||
})
|
||||
|
||||
it('logs nothing when the browser cancels a streamed asset response', async () => {
|
||||
const assets = writeFixture()
|
||||
const file = writeFile(join(assets, 'large.png'), Buffer.alloc(8 * 1024 * 1024, 7))
|
||||
const { port, warnings } = await composeHost()
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const outgoing = request({ host: '127.0.0.1', port, path: assetUrl(ASSETS_ID, 'large.png'), agent: false }, (res) => {
|
||||
res.once('data', () => {
|
||||
outgoing.destroy()
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
// The destroyed request reports its own abort; the cancellation is the point of the test.
|
||||
outgoing.on('error', () => undefined)
|
||||
outgoing.on('close', () => { resolve() })
|
||||
outgoing.end()
|
||||
})
|
||||
const stream = opened.findLast(entry => entry.path === file)?.stream
|
||||
if (stream === undefined) throw new Error('the asset route opened no file stream')
|
||||
// events.once would reject on the premature-close error the cancelled pipeline reports; only the close matters here.
|
||||
if (!stream.closed) await new Promise<void>((resolve) => { stream.once('close', () => { resolve() }) })
|
||||
// pipeline settles its rejection in the turn after the file stream closes; the route logs nothing then.
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('closes the file stream when a shell-carrier body is cancelled', async () => {
|
||||
const assets = writeFixture()
|
||||
const { service } = await composeHost()
|
||||
const response = await service.fetchBundle(new Request(`http://localhost${assetUrl(ASSETS_ID, 'photo.png')}`))
|
||||
const body = opened.findLast(entry => entry.path === join(assets, 'photo.png'))?.stream
|
||||
|
||||
await response.body?.cancel()
|
||||
expect(body?.destroyed).toBe(true)
|
||||
})
|
||||
|
||||
const base = `/plugins/${ASSETS_ID}/assets`
|
||||
it.each([
|
||||
['an excluded export subpath', `${base}/private/hidden.png`],
|
||||
['a package without the asset export', `/plugins/${NO_EXPORT_ID}/assets/photo.png`],
|
||||
['a missing file', `${base}/missing.png`],
|
||||
['`..` leaving the asset prefix', `${base}/../../secret.png`],
|
||||
['`%2e%2e` leaving the asset prefix', `${base}/%2e%2e/%2e%2e/secret.png`],
|
||||
['an encoded `..` path', `${base}/..%2f..%2fsecret.png`],
|
||||
['an empty asset path', `${base}/`],
|
||||
['a malformed percent-escape', `${base}/photo%zz.png`],
|
||||
['a decoded `%`', `${base}/100%25.png`],
|
||||
['a decoded `#` that Node reads as a fragment', `${base}/a%23b.png`],
|
||||
['an unsupported extension', `${base}/notes.txt`],
|
||||
['a directory', `${base}/folder.png`],
|
||||
['an inactive package', `/plugins/${INACTIVE_ID}/assets/photo.png`],
|
||||
])('answers 404 for %s', async (_case, url) => {
|
||||
writeFixture()
|
||||
const { both } = await composeHost()
|
||||
|
||||
expect(await both(url)).toEqual({ status: 404, headers: {}, body: Buffer.alloc(0) })
|
||||
})
|
||||
})
|
||||
@@ -6,18 +6,34 @@
|
||||
* lightningcss inside the bundle: `x.module.css` yields its hashed class map
|
||||
* and injects a tagged style at factory execution, while `x.css?inline`
|
||||
* exports compiled text for a plugin-owned lifecycle effect. The virtual
|
||||
* loaders register each real stylesheet as a watch dependency. SVG, PNG, and
|
||||
* WebP imports become watched base64 data URLs inside the bundle.
|
||||
* loaders register each real stylesheet as a watch dependency. Images under
|
||||
* `src/client/assets/`, imported from Client code or referenced by relative
|
||||
* `url()` in the package's own stylesheets, are emitted under `lib/assets/`
|
||||
* with their content revision in the file name and become document-relative
|
||||
* URLs served by the
|
||||
* client-modules asset route; such a package exports `./assets/*` as
|
||||
* `./lib/assets/*`, and its Node Client face rejects asset imports.
|
||||
* Non-experimental client outputs reject experimental module and stylesheet
|
||||
* inputs, including origins recorded by chained source maps.
|
||||
*/
|
||||
import { readFile, stat, utimes } from 'node:fs/promises'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { readFile, readdir, rm, rmdir, stat, utimes } from 'node:fs/promises'
|
||||
import { existsSync, readFileSync, realpathSync } from 'node:fs'
|
||||
import { createRequire, isBuiltin } from 'node:module'
|
||||
import { basename, dirname, extname, isAbsolute, relative, resolve as resolvePath, sep } from 'node:path'
|
||||
import { basename, dirname, isAbsolute, join, posix, relative, resolve as resolvePath, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Rolldown, type TsdownPlugin, type UserConfig } from 'tsdown'
|
||||
import { transform } from 'lightningcss'
|
||||
import { transform, type CustomAtRules, type Url, type Visitor } from 'lightningcss'
|
||||
import {
|
||||
PACKAGE_ASSET_EXPORT,
|
||||
PACKAGE_ASSET_EXPORT_TARGET,
|
||||
PACKAGE_ASSET_FILES_ENTRY,
|
||||
PACKAGE_ASSET_MEDIA_TYPES,
|
||||
packageAssetMediaType,
|
||||
packageAssetReference,
|
||||
packageAssetRevision,
|
||||
packageAssetRevisionedPath,
|
||||
} from './modules/src/assets.ts'
|
||||
import { optionalStringArray } from './modules/src/client/manifest.ts'
|
||||
import { PLATFORM_MODULES, PRELOADED_CLIENT_EXTERNALS } from './web/src/platform.ts'
|
||||
import { clientBuildEnvironmentDefines, clientBuildValueDefines } from '../../scripts/client-build-environment.ts'
|
||||
@@ -35,10 +51,10 @@ const INLINE_CSS_VIRTUAL_PREFIX = '\0dsh-inline-css:'
|
||||
const CSS_VIRTUAL_SUFFIX = '.mjs'
|
||||
const INLINE_CSS_QUERY = '?inline'
|
||||
|
||||
/** Image imports become base64 data URLs embedded in the plugin's own bundle. */
|
||||
const IMAGE_MEDIA_TYPES = new Map([['.svg', 'image/svg+xml'], ['.png', 'image/png'], ['.webp', 'image/webp']])
|
||||
|
||||
/** Emit one plugin-owned style injector and an optional CSS Modules export. */
|
||||
/**
|
||||
* Emit one plugin-owned style injector and an optional CSS Modules export.
|
||||
* @param css - JavaScript expression yielding the stylesheet text; see {@link stylesheetSource}.
|
||||
*/
|
||||
function styleInjectionModule(
|
||||
id: string,
|
||||
fileId: string,
|
||||
@@ -46,7 +62,7 @@ function styleInjectionModule(
|
||||
classMap?: Readonly<Record<string, string>>,
|
||||
): string {
|
||||
const source = [
|
||||
`const css = ${JSON.stringify(css)};`,
|
||||
`const css = ${css};`,
|
||||
`const tagId = ${JSON.stringify(`${id}/${basename(fileId)}`)};`,
|
||||
'if (typeof document !== \'undefined\' && document.querySelector(\'style[data-plugin-css=\' + JSON.stringify(tagId) + \']\') === null) {',
|
||||
' const tag = document.createElement(\'style\');',
|
||||
@@ -106,7 +122,9 @@ function browserSourcePath(source: string, sourcemapPath: string): string {
|
||||
* original source content, into the standalone plugin map.
|
||||
* @param packageDir - the package's own directory, normally the config's
|
||||
* `import.meta.dirname`. Its `package.json` name is the plugin id stamped into
|
||||
* the __ModuleLoader__.load handoff and onto the injected style tags.
|
||||
* the __ModuleLoader__.load handoff and onto the injected style tags, and the
|
||||
* owner segment of its asset URLs; its `src/client/assets/` holds the only
|
||||
* images the browser bundle may reference.
|
||||
* @param libEntry - node-half entries.
|
||||
* @param options - phase placement, lib overrides, optional independently bundled Node Client, companion configs, and per-file Client banner.
|
||||
* @returns ENV-selected tsdown config for the current build face.
|
||||
@@ -122,7 +140,7 @@ export function clientBundle(
|
||||
return ({ env }) => {
|
||||
const face = buildFace(env?.DSH_BUILD_FACE)
|
||||
const clientEntry = face === undefined ? 'src/client/index.ts' : 'lib/types/client/index.js'
|
||||
const client = clientConfig(manifest, clientEntry, options.clientBanner, options.dualClient === true)
|
||||
const client = clientConfig(manifest, packageDir, clientEntry, options.clientBanner, options.dualClient === true)
|
||||
const node = [lib, ...(options.companions ?? [])]
|
||||
const clientFaces = options.dualClient === true
|
||||
? [nodeClientConfig(manifest, clientEntry), client]
|
||||
@@ -277,6 +295,11 @@ interface AssetEmitter {
|
||||
}): string
|
||||
}
|
||||
|
||||
/** The slice of the rolldown plugin context that emits and watches one package asset. */
|
||||
interface AssetContext extends AssetEmitter {
|
||||
addWatchFile(id: string): void
|
||||
}
|
||||
|
||||
function staticLinkedConfig(id: string, entry: string, outputName = basename(entry, '.js')): UserConfig {
|
||||
const emitted = new Set<string>()
|
||||
const isolation = clientInputIsolation(id)
|
||||
@@ -358,6 +381,8 @@ interface PackageManifest {
|
||||
readonly dependencies?: Record<string, string>
|
||||
readonly peerDependencies?: Record<string, string>
|
||||
readonly optionalDependencies?: Record<string, string>
|
||||
/** Package exports; the browser face emits assets only while `./assets/*` publishes `lib/assets`. */
|
||||
readonly exports?: Readonly<Record<string, unknown>>
|
||||
readonly dsh?: { readonly client?: { readonly external?: unknown } }
|
||||
}
|
||||
|
||||
@@ -478,6 +503,255 @@ function asyncChunkRequirePlugin(): TsdownPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/** Emitted asset directory below the browser config's `lib` outDir, published as {@link PACKAGE_ASSET_FILES_ENTRY}. */
|
||||
const ASSET_OUTPUT_DIR = posix.relative('lib', PACKAGE_ASSET_FILES_ENTRY)
|
||||
|
||||
/** Characters the asset route rejects in a decoded path segment, because Node resolves export subpaths as URL text. */
|
||||
const UNSERVABLE_ASSET_SEGMENT = /[%#?\\\0]/
|
||||
|
||||
/** A `url()` value with a scheme, such as `data:` or `https:`, which names no package file. */
|
||||
const URL_SCHEME = /^[a-z][a-z\d+.-]*:/i
|
||||
|
||||
/**
|
||||
* Free identifier an asset reference compiles to before rendering; the CommonJS
|
||||
* render turns each call into `require.asset(...)`, the module loader's
|
||||
* synchronous asset resolver, which returns the reference unchanged unless its
|
||||
* carrier maps it. The fixed hexadecimal suffix keeps plugin strings that happen
|
||||
* to contain the shorter prefix out of that rewrite, and keeps the source maps
|
||||
* that carry the loaded module text byte-stable across builds.
|
||||
*/
|
||||
const PACKAGE_ASSET_CALL = '__dshPackageAsset_5f8e2c1a9b7d'
|
||||
|
||||
/** Compile one asset reference into the resolver call. */
|
||||
function packageAssetCall(reference: string): string {
|
||||
return `${PACKAGE_ASSET_CALL}(${JSON.stringify(reference)})`
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile stylesheet text into a JavaScript expression that passes each package-asset
|
||||
* reference the `url()` rewrite inserted through the resolver call. The rewrite writes
|
||||
* a placeholder of letters, digits, and underscores, which lightningcss never escapes,
|
||||
* with a per-stylesheet random part that stylesheet text cannot contain by chance.
|
||||
* A placeholder may appear several times or not at all, as when minification copies
|
||||
* or drops a declaration.
|
||||
* @param css - stylesheet text after the rewrite.
|
||||
* @param references - reference of each placeholder the rewrite inserted into this stylesheet.
|
||||
* @returns a string literal, or a concatenation of literals and resolver calls.
|
||||
*/
|
||||
function stylesheetSource(css: string, references: ReadonlyMap<string, string>): string {
|
||||
if (references.size === 0) return JSON.stringify(css)
|
||||
const parts: string[] = []
|
||||
let last = 0
|
||||
for (const match of css.matchAll(new RegExp([...references.keys()].join('|'), 'gu'))) {
|
||||
parts.push(JSON.stringify(css.slice(last, match.index)), packageAssetCall(references.get(match[0]) as string))
|
||||
last = match.index + match[0].length
|
||||
}
|
||||
parts.push(JSON.stringify(css.slice(last)))
|
||||
return parts.join(' + ')
|
||||
}
|
||||
|
||||
/** One stylesheet's `url()` rewrite: the lightningcss options and the references they insert while transforming. */
|
||||
interface StylesheetAssets {
|
||||
/** lightningcss options; empty when the stylesheet lies outside the package's own `src`. */
|
||||
readonly options: { readonly visitor?: Visitor<CustomAtRules> }
|
||||
/** Reference of each placeholder inserted during the transform that uses {@link options}. */
|
||||
readonly references: ReadonlyMap<string, string>
|
||||
}
|
||||
|
||||
/** Browser-face package assets: the import plugin and the stylesheet `url()` rewrite share one per-build emission record. */
|
||||
interface PackageAssets {
|
||||
/** `dsh-package-asset`: resolves asset imports, emits referenced files, and prunes stale `lib/assets` files after writing. */
|
||||
readonly plugin: TsdownPlugin
|
||||
/**
|
||||
* Select the lightningcss options that rewrite one stylesheet's relative `url()` values.
|
||||
* @param context - plugin context of the stylesheet loader, which emits each referenced asset.
|
||||
* @param stylesheet - physical stylesheet path.
|
||||
* @returns a `url()` visitor when the stylesheet lies inside the package's own `src`, otherwise no options,
|
||||
* and the references the visitor inserts.
|
||||
* @throws {Error} from the visitor when a relative `url()` does not name a publishable package asset.
|
||||
*/
|
||||
urlRewrite(context: AssetContext, stylesheet: string): StylesheetAssets
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the package-asset rules of one dynamic browser bundle. A referenced
|
||||
* file must lie under the package's own `src/client/assets/`, have a supported
|
||||
* extension, and be published by the `./assets/*` export; it is emitted once
|
||||
* per build under `lib/assets/` at its relative path with the content revision
|
||||
* inserted before the extension, and the reference compiles to the module loader's
|
||||
* synchronous resolver call on its document-relative URL.
|
||||
* A one-shot build deletes files under `lib/assets/` that it did not emit; a
|
||||
* watch build keeps them for pages still running an earlier generation.
|
||||
* @param manifest - the package's own manifest, naming the URL owner and declaring the asset export.
|
||||
* @param packageDir - the package's own directory.
|
||||
* @returns the plugin and stylesheet visitor sharing the per-build emission record.
|
||||
*/
|
||||
function packageAssets(manifest: PackageManifest, packageDir: string): PackageAssets {
|
||||
const name = manifest.name
|
||||
const sourceDir = resolvePath(packageDir, 'src')
|
||||
const assetsDir = resolvePath(sourceDir, 'client', 'assets')
|
||||
/** Reference of every asset emitted in the current build, keyed by its real path. */
|
||||
const references = new Map<string, string>()
|
||||
|
||||
const reference = (context: AssetContext, file: string): string => {
|
||||
let realPath: string
|
||||
try {
|
||||
realPath = realpathSync(file)
|
||||
} catch (error) {
|
||||
throw new Error(`tsdown: ${name}: cannot resolve asset ${file}`, { cause: error })
|
||||
}
|
||||
const emitted = references.get(realPath)
|
||||
if (emitted !== undefined) return emitted
|
||||
const below = pathBelow(realDirectory(assetsDir), realPath)
|
||||
if (below === undefined) throw new Error(`tsdown: ${name}: ${file} is outside src/client/assets; move the asset there`)
|
||||
const assetPath = below.split(sep).join('/')
|
||||
if (assetPath.split('/').some(segment => UNSERVABLE_ASSET_SEGMENT.test(segment) || segment.toLowerCase() === 'node_modules')) {
|
||||
throw new Error(`tsdown: ${name}: ${file} has a node_modules path segment or one containing %, #, ?, \\ or NUL, which the asset route cannot serve; rename it`)
|
||||
}
|
||||
const extension = posix.extname(assetPath)
|
||||
if (extension !== extension.toLowerCase()) {
|
||||
throw new Error(`tsdown: ${name}: ${file} has an uppercase extension; asset imports are typed by lowercase extensions, so rename it`)
|
||||
}
|
||||
if (manifest.exports?.[PACKAGE_ASSET_EXPORT] !== PACKAGE_ASSET_EXPORT_TARGET) {
|
||||
throw new Error(
|
||||
`tsdown: ${name}: ${file} needs package.json exports ${JSON.stringify({ [PACKAGE_ASSET_EXPORT]: PACKAGE_ASSET_EXPORT_TARGET })} `
|
||||
+ `and ${JSON.stringify(PACKAGE_ASSET_FILES_ENTRY)} in files`,
|
||||
)
|
||||
}
|
||||
if (packageAssetMediaType(assetPath) === undefined) {
|
||||
throw new Error(`tsdown: ${name}: ${file} is not a supported asset; supported extensions: ${[...PACKAGE_ASSET_MEDIA_TYPES.keys()].join(', ')}`)
|
||||
}
|
||||
const bytes = readFileSync(realPath)
|
||||
const emittedPath = packageAssetRevisionedPath(assetPath, packageAssetRevision(bytes))
|
||||
context.emitFile({ type: 'asset', fileName: `${ASSET_OUTPUT_DIR}/${emittedPath}`, source: bytes, originalFileName: realPath })
|
||||
context.addWatchFile(realPath)
|
||||
const url = packageAssetReference(name, emittedPath)
|
||||
references.set(realPath, url)
|
||||
return url
|
||||
}
|
||||
|
||||
return {
|
||||
plugin: {
|
||||
name: 'dsh-package-asset',
|
||||
buildStart() { references.clear() },
|
||||
resolveId(source, importer) {
|
||||
if (importer === undefined || packageAssetMediaType(source) === undefined) return null
|
||||
return sourceAssetPath(source, importer)
|
||||
},
|
||||
load(id) {
|
||||
if (!isAbsolute(id) || packageAssetMediaType(id) === undefined) return null
|
||||
return `export default ${packageAssetCall(reference(this, id))};`
|
||||
},
|
||||
renderChunk(code, _chunk, outputOptions) {
|
||||
if (outputOptions.format !== 'cjs' || !code.includes(PACKAGE_ASSET_CALL)) return null
|
||||
const transformed = new Rolldown.RolldownMagicString(code)
|
||||
for (const match of code.matchAll(new RegExp(`\\b${PACKAGE_ASSET_CALL}\\(`, 'gu'))) {
|
||||
transformed.overwrite(match.index, match.index + match[0].length, 'require.asset(')
|
||||
}
|
||||
return transformed
|
||||
},
|
||||
async writeBundle(outputOptions, bundle) {
|
||||
// A watch rebuild keeps earlier files: the Host still serves the previous
|
||||
// bundle generation, and pages running it request those asset names.
|
||||
if (this.meta.watchMode || outputOptions.dir === undefined) return
|
||||
const outDir = outputOptions.dir
|
||||
const assets = resolvePath(outDir, ASSET_OUTPUT_DIR)
|
||||
if (!existsSync(assets)) return
|
||||
await pruneDirectory(assets, file => relative(outDir, file).split(sep).join('/') in bundle)
|
||||
},
|
||||
},
|
||||
urlRewrite(context, stylesheet) {
|
||||
const inserted = new Map<string, string>()
|
||||
const nonce = randomBytes(6).toString('hex')
|
||||
if (pathBelow(realDirectory(sourceDir), realpathSync(stylesheet)) === undefined) return { options: {}, references: inserted }
|
||||
const base = dirname(stylesheet)
|
||||
const rewrite = (url: Url): Url | undefined => {
|
||||
// Empty values, fragments, root-relative paths, schemes such as `data:`, and placeholders name no package file.
|
||||
if (url.url === '' || url.url.startsWith('#') || url.url.startsWith('/') || URL_SCHEME.test(url.url)) return undefined
|
||||
if (inserted.has(url.url)) return undefined
|
||||
if (/[#?]/u.test(url.url)) {
|
||||
throw new Error(`tsdown: ${name}: ${stylesheet} references ${JSON.stringify(url.url)}; a package asset url() takes no fragment or query`)
|
||||
}
|
||||
const placeholder = `__dshPackageAsset_${nonce}_${String(inserted.size)}__`
|
||||
inserted.set(placeholder, reference(context, resolvePath(base, url.url)))
|
||||
return { ...url, url: placeholder }
|
||||
}
|
||||
return {
|
||||
options: {
|
||||
visitor: {
|
||||
Url: url => rewrite(url),
|
||||
// lightningcss does not visit the urls inside image-set(), so its options are rewritten here.
|
||||
Image(image) {
|
||||
if (image.type !== 'image-set') return undefined
|
||||
const options = image.value.options.map((option) => {
|
||||
const url = option.image.type === 'url' ? rewrite(option.image.value) : undefined
|
||||
return url === undefined ? option : { ...option, image: { type: 'url' as const, value: url } }
|
||||
})
|
||||
return { ...image, value: { ...image.value, options } }
|
||||
},
|
||||
},
|
||||
},
|
||||
references: inserted,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Real path of an existing directory, or undefined when it does not exist. */
|
||||
function realDirectory(directory: string): string | undefined {
|
||||
return existsSync(directory) ? realpathSync(directory) : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate a real path strictly inside a real directory.
|
||||
* @param root - real directory path, or undefined when the directory does not exist.
|
||||
* @param file - real file path.
|
||||
* @returns the platform-separated path of `file` below `root`, or undefined when it is not inside.
|
||||
*/
|
||||
function pathBelow(root: string | undefined, file: string): string | undefined {
|
||||
if (root === undefined) return undefined
|
||||
const path = relative(root, file)
|
||||
return path === '' || path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path) ? undefined : path
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete every file a predicate rejects below a directory, then every directory left empty.
|
||||
* @param directory - directory to prune; removed itself when nothing remains.
|
||||
* @param keep - whether a file path survives.
|
||||
* @returns whether the directory was removed.
|
||||
*/
|
||||
async function pruneDirectory(directory: string, keep: (file: string) => boolean): Promise<boolean> {
|
||||
let remaining = 0
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
const path = join(directory, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
if (!(await pruneDirectory(path, keep))) remaining++
|
||||
} else if (keep(path)) {
|
||||
remaining++
|
||||
} else {
|
||||
await rm(path)
|
||||
}
|
||||
}
|
||||
if (remaining > 0) return false
|
||||
await rmdir(directory)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject asset imports in a Node Client face, which has no asset route.
|
||||
* @param id - package name, used in diagnostics.
|
||||
* @returns a plugin whose resolveId throws for every supported asset extension.
|
||||
*/
|
||||
function browserOnlyAssets(id: string): TsdownPlugin {
|
||||
return {
|
||||
name: 'dsh-package-asset-browser-only',
|
||||
resolveId(source) {
|
||||
if (packageAssetMediaType(source) === undefined) return null
|
||||
throw new Error(`tsdown: ${id}: asset imports are browser-only (${source})`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Bundle the Client entry for plain Node independently of the Host entry and browser factory. */
|
||||
function nodeClientConfig(manifest: PackageManifest, entry: string): UserConfig {
|
||||
const isolation = clientInputIsolation(manifest.name)
|
||||
@@ -486,7 +760,7 @@ function nodeClientConfig(manifest: PackageManifest, entry: string): UserConfig
|
||||
entry: { 'client.node': entry },
|
||||
sourcemap: true,
|
||||
inputOptions: { resolve: { conditionNames: ['node', 'import', 'module', 'default'] } },
|
||||
plugins: [tscSourceMapPlugin(), isolation.plugin],
|
||||
plugins: [browserOnlyAssets(manifest.name), tscSourceMapPlugin(), isolation.plugin],
|
||||
outputOptions: {
|
||||
entryFileNames: 'client.node.js',
|
||||
chunkFileNames: 'client.node.[name].js',
|
||||
@@ -498,6 +772,7 @@ function nodeClientConfig(manifest: PackageManifest, entry: string): UserConfig
|
||||
|
||||
function clientConfig(
|
||||
manifest: PackageManifest,
|
||||
packageDir: string,
|
||||
entry: string,
|
||||
clientBanner?: (fileName: string) => string | undefined,
|
||||
dualClient = false,
|
||||
@@ -506,6 +781,7 @@ function clientConfig(
|
||||
let requested: ReadonlySet<string> | undefined
|
||||
const isRequested = (specifier: string): boolean => (requested ??= clientExternals(manifest)).has(specifier)
|
||||
const isolation = clientInputIsolation(id)
|
||||
const assets = packageAssets(manifest, packageDir)
|
||||
return {
|
||||
name: `${id}/client`,
|
||||
entry: { client: entry },
|
||||
@@ -589,17 +865,19 @@ function clientConfig(
|
||||
// The virtual id otherwise hides the physical stylesheet from Rolldown's watch graph.
|
||||
this.addWatchFile(fileId)
|
||||
const source = await readFile(fileId)
|
||||
const stylesheetAssets = assets.urlRewrite(this, fileId)
|
||||
const { code, exports: cssExports } = transform({
|
||||
filename: fileId,
|
||||
code: source,
|
||||
cssModules: { pattern: '[hash]_[local]' },
|
||||
minify: true,
|
||||
...stylesheetAssets.options,
|
||||
})
|
||||
const classMap: Record<string, string> = {}
|
||||
const exportEntries = Object.entries(cssExports ?? {})
|
||||
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||
for (const [local, exp] of exportEntries) classMap[local] = exp.name
|
||||
return styleInjectionModule(id, fileId, code.toString(), classMap)
|
||||
return styleInjectionModule(id, fileId, stylesheetSource(code.toString(), stylesheetAssets.references), classMap)
|
||||
},
|
||||
}, {
|
||||
name: 'dsh-css-text-inline',
|
||||
@@ -614,8 +892,9 @@ function clientConfig(
|
||||
const fileId = virtualId.slice(INLINE_CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
|
||||
this.addWatchFile(fileId)
|
||||
const source = await readFile(fileId)
|
||||
const { code } = transform({ filename: fileId, code: source, minify: true })
|
||||
return `export default ${JSON.stringify(code.toString())};`
|
||||
const stylesheetAssets = assets.urlRewrite(this, fileId)
|
||||
const { code } = transform({ filename: fileId, code: source, minify: true, ...stylesheetAssets.options })
|
||||
return `export default ${stylesheetSource(code.toString(), stylesheetAssets.references)};`
|
||||
},
|
||||
}, {
|
||||
name: 'dsh-css-global-inline',
|
||||
@@ -629,23 +908,11 @@ function clientConfig(
|
||||
const fileId = virtualId.slice(GLOBAL_CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
|
||||
this.addWatchFile(fileId)
|
||||
const source = await readFile(fileId)
|
||||
const { code } = transform({ filename: fileId, code: source, minify: true })
|
||||
return styleInjectionModule(id, fileId, code.toString())
|
||||
const stylesheetAssets = assets.urlRewrite(this, fileId)
|
||||
const { code } = transform({ filename: fileId, code: source, minify: true, ...stylesheetAssets.options })
|
||||
return styleInjectionModule(id, fileId, stylesheetSource(code.toString(), stylesheetAssets.references))
|
||||
},
|
||||
}, {
|
||||
name: 'dsh-image-inline',
|
||||
resolveId(source: string, importer: string | undefined) {
|
||||
if (importer === undefined || !IMAGE_MEDIA_TYPES.has(extname(source))) return null
|
||||
return sourceAssetPath(source, importer)
|
||||
},
|
||||
async load(fileId: string) {
|
||||
const mediaType = IMAGE_MEDIA_TYPES.get(extname(fileId))
|
||||
if (mediaType === undefined) return null
|
||||
this.addWatchFile(fileId)
|
||||
const data = await readFile(fileId)
|
||||
return `export default ${JSON.stringify(`data:${mediaType};base64,${data.toString('base64')}`)};`
|
||||
},
|
||||
}],
|
||||
}, assets.plugin],
|
||||
outputOptions: {
|
||||
entryFileNames: dualClient ? 'client.browser.js' : 'client.js',
|
||||
// The imported source basename becomes the published chunk name; package
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
en: b0d806604bc17006
|
||||
zh: 18dabe5dc2d0d419
|
||||
/deepseek-ai-dsh-client-ui-chat/grouped-rendering:
|
||||
en: f78cbc73be5dc3ac
|
||||
zh: 893ba03d66b8ab65
|
||||
en: a14c74ed7de0ef9c
|
||||
zh: d5e618b2ef809150
|
||||
/deepseek-ai-dsh-client-ui-chat/scroll-ownership:
|
||||
en: 284737900cb75668
|
||||
zh: 12e0844082ff79ce
|
||||
|
||||
@@ -111,7 +111,7 @@ The process group uses a stable `div` layout box, a scroll body, and an uncapped
|
||||
|
||||
Running group titles, reasoning previews, tool and command rows, the active retry line, and the bottom running status use the shared [TextShimmer](../ui-primitives/README.md#component-catalog). Each row’s text shares one left-to-right highlight, including collapsed group titles; icons and hover chevrons retain their base appearance. Tool and process rows sit 6px apart. An expanded group title has 8px before its content, Assistant responses have 12px from adjacent process rows, and completed-Turn duration/status controls have 16px from their input and from a directly following visible row. A reply that follows the control after folded rows keeps the 12px reply gap, so folding never changes its spacing. Hidden and empty rows, including retained empty slot anchors, add no gap. The running “Deep diving” text retains its blue base while its theme-specific highlight sweeps across it.
|
||||
|
||||
The running status shows a whale whose APNG mask inherits the text color. CSS selects the static SVG when reduced motion or forced colors are active, or alpha masks are unsupported. The build embeds the image in the plugin stylesheet; React does not pass image data through DOM styles.
|
||||
The running status shows a whale whose APNG mask inherits the text color. CSS selects the static SVG when reduced motion or forced colors are active, or alpha masks are unsupported. The APNG is a [package asset](../AGENTS.md#dependency-declaration) referenced by the plugin stylesheet; React does not pass image data through DOM styles.
|
||||
|
||||
Scroll-edge fades initialize when `ResizeObserver` reports the open group's layout; opening the group performs no immediate scroll-dimension read in a layout effect.
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ Chat Builder 按节点发布 `bottomSource`:节点可见且属于已加载的
|
||||
|
||||
运行中的分组标题、思考预览、工具与命令行、当前重试行以及底部运行状态使用共享的 [TextShimmer](../ui-primitives/README.zh.md#component-catalog)。每行的文字共用一道从左到右的高光,包括折叠的分组标题;图标和悬停箭头保留底色。工具和过程行之间相隔 6px;展开的分组标题与内容之间相隔 8px;Assistant 回复与相邻过程行之间相隔 12px;每轮完成/耗时控件与其输入、以及紧随其后的可见行之间保留 16px。控件与回复之间隔着已收起的行时,回复保持 12px 回复间距,因此折叠不会改变它的间距。隐藏行与空行(包括保留的空 slot 锚点)不产生间距。“深度求索”运行文字保留蓝色底色,并使用主题专用高光扫过文字。
|
||||
|
||||
运行状态显示一只鲸鱼,其 APNG 遮罩继承文字颜色。启用减少动态效果或强制颜色模式、或浏览器不支持 alpha 遮罩时,CSS 选择静态 SVG。构建将图片嵌入插件样式表;React 不通过 DOM 样式传递图片数据。
|
||||
运行状态显示一只鲸鱼,其 APNG 遮罩继承文字颜色。启用减少动态效果或强制颜色模式、或浏览器不支持 alpha 遮罩时,CSS 选择静态 SVG。APNG 是插件样式表引用的[包资源](../AGENTS.md#dependency-declaration);React 不通过 DOM 样式传递图片数据。
|
||||
|
||||
滚动边缘渐隐在 `ResizeObserver` 报告已展开组的布局后初始化;展开组时不会在 layout effect 中立即读取滚动尺寸。
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./assets/*": "./lib/assets/*",
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json",
|
||||
"./locale/*.json": "./locale/*.json"
|
||||
@@ -101,6 +102,7 @@
|
||||
"locale/*.json",
|
||||
"lib/index.js",
|
||||
"lib/client.js",
|
||||
"lib/assets",
|
||||
"lib/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
File renamed without changes.
@@ -327,7 +327,7 @@
|
||||
.runningWhaleAnimated {
|
||||
display: block;
|
||||
background: currentColor;
|
||||
mask: url('./running-whale@2x.png') center / 100% 100% no-repeat;
|
||||
mask: url('../assets/running-whale@2x.png') center / 100% 100% no-repeat;
|
||||
mask-mode: alpha;
|
||||
/* Keep animated-mask repaints on an icon-sized composited layer. */
|
||||
will-change: transform;
|
||||
|
||||
@@ -28,7 +28,7 @@ describe('RunningWhaleTail', () => {
|
||||
})
|
||||
|
||||
it('ships a 28px alpha APNG with sixty 50ms frames and infinite playback', () => {
|
||||
const png = readFileSync(resolve(import.meta.dirname, '../src/client/chat/running-whale@2x.png'))
|
||||
const png = readFileSync(resolve(import.meta.dirname, '../src/client/assets/running-whale@2x.png'))
|
||||
expect([...png.subarray(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10])
|
||||
const chunks: { type: string; data: Buffer }[] = []
|
||||
for (let offset = 8; offset < png.length;) {
|
||||
|
||||
@@ -1,22 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const bundle = clientBundle(import.meta.dirname, ['lib/types/index.js'])
|
||||
const stylesheet = fileURLToPath(new URL('./src/client/chat/ChatView.module.css', import.meta.url)).replaceAll('\\', '/')
|
||||
const whaleImage = fileURLToPath(new URL('./src/client/chat/running-whale@2x.png', import.meta.url))
|
||||
|
||||
export default ((options) => bundle(options).map(config => ({
|
||||
...config,
|
||||
plugins: [...(config.plugins ?? []), {
|
||||
name: 'chat-whale-image',
|
||||
async transform(code: string, id: string) {
|
||||
if (id.replaceAll('\\', '/') !== `\0dsh-css:${stylesheet}.mjs`) return null
|
||||
const imageUrl = './running-whale@2x.png'
|
||||
if (!code.includes(imageUrl)) return null
|
||||
this.addWatchFile(whaleImage)
|
||||
const image = await readFile(whaleImage)
|
||||
return { code: code.replaceAll(imageUrl, `data:image/png;base64,${image.toString('base64')}`), map: null }
|
||||
},
|
||||
}],
|
||||
}))) satisfies typeof bundle
|
||||
export default clientBundle(import.meta.dirname, ['lib/types/index.js'])
|
||||
@@ -21,8 +21,8 @@
|
||||
en: c1c4db71fd09ca13
|
||||
zh: a40c0a01e9951945
|
||||
/deepseek-ai-dsh-client-ui-settings-account/understand-the-implementation:
|
||||
en: 149d25b949a24036
|
||||
zh: a432400e245bf48b
|
||||
en: 186b2046cfb5ef76
|
||||
zh: 57b042a618fb612e
|
||||
/deepseek-ai-dsh-client-ui-settings-account/further-exploration:
|
||||
en: f5ca95916ef486dc
|
||||
zh: 1e238d8299e0d838
|
||||
|
||||
@@ -68,7 +68,7 @@ The welcome page requires Get started. The credit page always appears. A positiv
|
||||
|
||||
The Account section appears first in Settings only while signed in; it is hidden before account state loads and after sign-out. The plugin owns one Host snapshot stream shared through framework hooks by settings.section and settings.launcher. The launcher opens Settings and offers Sign out only while an account credential is stored. Platform failures leave the menu available for retry. It maintains no independent credential state.
|
||||
|
||||
The desktop controller writes progress to `ui-settings-account` through Host settings. Completion first applies `ui-chat.transcriptView` and `ui-chat.performanceUsage` together, then calls the shared Coding Tools preference to persist `ui-settings.enabled`, and finally saves the done marker; a rejected write keeps the flow available for retry. Successful completion reveals the workspace through a 180ms fade; choices and ordinary navigation preview immediately while writes run in order. A failed final queued write restores saved progress and allows retry. Completion waits for queued choices and retains the page until preferences persist; reduced motion skips the fade. Onboarding choices compact, standard, and detailed apply directly to the corresponding Chat work-detail modes. API-key entry and explicit skip select standard; onboarding does not select verbose. Complete Figma illustration layers are bundled as transparent, lossless 3× WebP assets; welcome layers are merged with local sidebar blur; recharge uses a 70% opaque foreground window with local blur of the covered illustration, and headings use the bundled Montserrat brand font; English headings and descriptions use Light (300), except the DeepSeek Harness brand within headings uses Medium (500); card titles retain Regular (400) when selected. English footer navigation uses Light (300). Confirmed onboarding sets a 960px minimum window width and enlarges narrower windows; completion releases the minimum without restoring the previous size. Loading or already-completed onboarding does not resize the window. Empty footer navigation space passes pointer events through to the primary action. Cards exclude native window dragging and own the keyboard focus outline for their checkbox. The account package owns its onboarding overlay and control geometry. Each step and the confirmation dialog has a separate component; the flow coordinates navigation, transitions, and recharge. The overlay hides the workspace while initial account and progress state loads. Question headings and cards follow the Figma positions at 1440 × 920 and 960 × 600, independently of their action buttons. On macOS, content clears the body portal’s inherited no-drag region and a first-child drag band leaves an 8px native resize margin; interactive controls and later dialogs exclude their regions, and a native Platform page disables the underlying onboarding band. The upper workspace popup illustration has an opaque background in both languages and themes; the underlying sidebar omits its selected-row highlight. Welcome keeps its illustration at its original size, repositions and clips the artwork in narrow windows, and reveals the complete illustration on wide windows. The [historical onboarding decision](../../../.agents/notes/archived/feature/2026-09-16-desktop-onboarding.md) owns persistence and presentation trade-offs.
|
||||
The desktop controller writes progress to `ui-settings-account` through Host settings. Completion first applies `ui-chat.transcriptView` and `ui-chat.performanceUsage` together, then calls the shared Coding Tools preference to persist `ui-settings.enabled`, and finally saves the done marker; a rejected write keeps the flow available for retry. Successful completion reveals the workspace through a 180ms fade; choices and ordinary navigation preview immediately while writes run in order. A failed final queued write restores saved progress and allows retry. Completion waits for queued choices and retains the page until preferences persist; reduced motion skips the fade. Onboarding choices compact, standard, and detailed apply directly to the corresponding Chat work-detail modes. API-key entry and explicit skip select standard; onboarding does not select verbose. Complete Figma illustration layers ship as transparent, lossless 3× WebP [package assets](../AGENTS.md#dependency-declaration); welcome layers are merged with local sidebar blur; recharge uses a 70% opaque foreground window with local blur of the covered illustration, and headings use the bundled Montserrat brand font; English headings and descriptions use Light (300), except the DeepSeek Harness brand within headings uses Medium (500); card titles retain Regular (400) when selected. English footer navigation uses Light (300). Confirmed onboarding sets a 960px minimum window width and enlarges narrower windows; completion releases the minimum without restoring the previous size. Loading or already-completed onboarding does not resize the window. Empty footer navigation space passes pointer events through to the primary action. Cards exclude native window dragging and own the keyboard focus outline for their checkbox. The account package owns its onboarding overlay and control geometry. Each step and the confirmation dialog has a separate component; the flow coordinates navigation, transitions, and recharge. The overlay hides the workspace while initial account and progress state loads. Question headings and cards follow the Figma positions at 1440 × 920 and 960 × 600, independently of their action buttons. On macOS, content clears the body portal’s inherited no-drag region and a first-child drag band leaves an 8px native resize margin; interactive controls and later dialogs exclude their regions, and a native Platform page disables the underlying onboarding band. The upper workspace popup illustration has an opaque background in both languages and themes; the underlying sidebar omits its selected-row highlight. Welcome keeps its illustration at its original size, repositions and clips the artwork in narrow windows, and reveals the complete illustration on wide windows. The [historical onboarding decision](../../../.agents/notes/archived/feature/2026-09-16-desktop-onboarding.md) owns persistence and presentation trade-offs.
|
||||
|
||||
<a id="further-exploration"></a>
|
||||
## Further Exploration
|
||||
|
||||
@@ -68,7 +68,7 @@ Desktop preload 标记在账号凭证已存储后启用引导。进度属于本
|
||||
|
||||
仅登录后在设置导航首位显示账号页;账号状态加载完成前以及退出登录后隐藏该页。 插件持有一条 Host 快照流,通过框架 hook 供 settings.section 和 settings.launcher 共享。入口菜单可打开设置,并仅在已存储账号凭证时提供退出登录。Platform 请求失败时保留菜单,以供重试。插件不维护独立凭证状态。
|
||||
|
||||
桌面控制器通过 Host 设置将进度写入 `ui-settings-account`。完成时先一并应用 `ui-chat.transcriptView` 与 `ui-chat.performanceUsage`,再通过共享代码工作工具偏好保存 `ui-settings.enabled`,最后保存完成标记;写入被拒绝时保留引导以供重试。成功完成后通过 180ms 淡出显露工作区;选择和普通翻页立即预览,写入在后台按顺序执行。队列最后一次写入失败时恢复已保存进度,并允许重试。完成操作会等待选择队列及偏好保存成功,再关闭页面;减少动态效果偏好会跳过淡出。onboarding 选项 compact、standard、detailed 直接应用到对应的 Chat 工作过程模式。API Key 进入与显式跳过均采用 standard;onboarding 不选择 verbose。完整 Figma 示意图层以透明、无损编码的 3× WebP 资源打包;欢迎页图层合并并烘焙侧栏局部模糊;充值页前景窗口不透明度为 70%,仅烘焙其覆盖插图区域的模糊,标题使用已打包的 Montserrat 品牌字体,英文标题和描述使用 Light(300),标题中的 DeepSeek Harness 使用 Medium(500);卡片标题选中前后均使用 Regular(400)。英文底部导航使用 Light(300)。确认进入引导后,窗口最小宽度设为 960px,较窄窗口自动放大;完成后解除宽度限制,但不恢复原尺寸。加载中或已完成引导的启动不改变窗口尺寸。底部导航空白区域透过鼠标事件,避免遮挡主操作。卡片区域不参与原生窗口拖动,复选框的键盘焦点仅在卡片外框展示。账号包负责引导覆盖层和控件尺寸样式。每个步骤和确认弹窗各有独立组件,流程组件协调导航、切换动画和充值。初始账号与进度加载期间,覆盖层隐藏工作区。答题标题和卡片独立于操作按钮,位置对齐 Figma 的 1440 × 920 和 960 × 600 断点。macOS 的内容层清除 body 浮层继承的 no-drag,位于内容之前的拖拽层保留 8px 原生缩放边缘;交互控件和后续弹窗排除自身区域;原生平台页面打开时禁用底层引导拖拽层。上方工作区弹窗插画在两种语言和主题下均使用不透明背景,下层侧边栏不绘制选中行高亮。欢迎页保持示意图的原始尺寸,在窄窗口内调整插图位置并裁切,在宽窗口内展示完整插图。[历史引导决策](../../../.agents/notes/archived/feature/2026-09-16-desktop-onboarding.md)记录持久化与展示方面的取舍。
|
||||
桌面控制器通过 Host 设置将进度写入 `ui-settings-account`。完成时先一并应用 `ui-chat.transcriptView` 与 `ui-chat.performanceUsage`,再通过共享代码工作工具偏好保存 `ui-settings.enabled`,最后保存完成标记;写入被拒绝时保留引导以供重试。成功完成后通过 180ms 淡出显露工作区;选择和普通翻页立即预览,写入在后台按顺序执行。队列最后一次写入失败时恢复已保存进度,并允许重试。完成操作会等待选择队列及偏好保存成功,再关闭页面;减少动态效果偏好会跳过淡出。onboarding 选项 compact、standard、detailed 直接应用到对应的 Chat 工作过程模式。API Key 进入与显式跳过均采用 standard;onboarding 不选择 verbose。完整 Figma 示意图层以透明、无损编码的 3× WebP [包资源](../AGENTS.md#dependency-declaration)形式提供;欢迎页图层合并并烘焙侧栏局部模糊;充值页前景窗口不透明度为 70%,仅烘焙其覆盖插图区域的模糊,标题使用已打包的 Montserrat 品牌字体,英文标题和描述使用 Light(300),标题中的 DeepSeek Harness 使用 Medium(500);卡片标题选中前后均使用 Regular(400)。英文底部导航使用 Light(300)。确认进入引导后,窗口最小宽度设为 960px,较窄窗口自动放大;完成后解除宽度限制,但不恢复原尺寸。加载中或已完成引导的启动不改变窗口尺寸。底部导航空白区域透过鼠标事件,避免遮挡主操作。卡片区域不参与原生窗口拖动,复选框的键盘焦点仅在卡片外框展示。账号包负责引导覆盖层和控件尺寸样式。每个步骤和确认弹窗各有独立组件,流程组件协调导航、切换动画和充值。初始账号与进度加载期间,覆盖层隐藏工作区。答题标题和卡片独立于操作按钮,位置对齐 Figma 的 1440 × 920 和 960 × 600 断点。macOS 的内容层清除 body 浮层继承的 no-drag,位于内容之前的拖拽层保留 8px 原生缩放边缘;交互控件和后续弹窗排除自身区域;原生平台页面打开时禁用底层引导拖拽层。上方工作区弹窗插画在两种语言和主题下均使用不透明背景,下层侧边栏不绘制选中行高亮。欢迎页保持示意图的原始尺寸,在窄窗口内调整插图位置并裁切,在宽窗口内展示完整插图。[历史引导决策](../../../.agents/notes/archived/feature/2026-09-16-desktop-onboarding.md)记录持久化与展示方面的取舍。
|
||||
|
||||
<a id="further-exploration"></a>
|
||||
## 深入探索
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./assets/*": "./lib/assets/*",
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json",
|
||||
"./locale/*.json": "./locale/*.json"
|
||||
@@ -75,6 +76,7 @@
|
||||
"locale/*.json",
|
||||
"lib/index.js",
|
||||
"lib/client.js",
|
||||
"lib/assets",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
/** Illustration imports are embedded in the account client bundle. */
|
||||
declare module '*.webp' {
|
||||
const url: string
|
||||
export default url
|
||||
}
|
||||
declare module '*.svg' {
|
||||
const url: string
|
||||
export default url
|
||||
}
|
||||
@@ -15,8 +15,8 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-client-web/use-this-package:
|
||||
en: 8778e166024b9628
|
||||
zh: 777d6923db199337
|
||||
en: 3105391bad2e9f06
|
||||
zh: fe0c5af5db6c241c
|
||||
/deepseek-ai-dsh-client-web/use-this-package/what-boot-looks-like:
|
||||
en: f3b01aa878764e5c
|
||||
zh: f02eab65d441bfe0
|
||||
|
||||
@@ -25,7 +25,7 @@ English | [中文](README.zh.md)
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
Use it when you assemble the browser application: `apps/web`'s Vite entry runs `new AppWebEntry(container).run()` against the mount point, and the boot page carries the user through activation. Ordinary browser callers pass no options. A pre-injected page transport is the default ahead of the `seams` override: when `globalThis.__DSH_TRANSPORT__` carries `loadBundle`, the module stage adopts it as the bundle transport and skips the immediate-tier HTTP prefetch, while explicit `seams` still win (for example jsdom tests, where external `<script>` execution cannot reach the page context).
|
||||
Use it when you assemble the browser application: `apps/web`'s Vite entry runs `new AppWebEntry(container).run()` against the mount point, and the boot page carries the user through activation. Ordinary browser callers pass no options. A pre-injected page transport is the default ahead of the `seams` override: when `globalThis.__DSH_TRANSPORT__` carries `loadBundle`, the module stage adopts it as the bundle transport and skips the immediate-tier HTTP prefetch; when it carries `resolveAsset`, the module stage adopts it as the resolver that maps each `require.asset` package-asset reference to a URL the page can load; while explicit `seams` still win (for example jsdom tests, where external `<script>` execution cannot reach the page context).
|
||||
|
||||
Static application pages install `__DSH_BOOT_READY__` before the entry runs. The boot page renders immediately while `run()` waits; the page owner applies the Host rows with `applyIndexInjections` (also exported from `./injections`) and resolves the deferred after all scripts finish. A rejected deferred renders a boot failure unless the caller supplies `run(onFailure)` to present the error externally while retaining the loading page. Desktop uses this callback to request native recovery. Desktop and WebWorker share the injection interpreter; server-side `tapIndex` HTML transforms apply only to served documents.
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ kind: "package-library"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
组装浏览器应用时使用它:`apps/web` 的 Vite 入口对挂载点运行 `new AppWebEntry(container).run()`,启动页会在激活过程中向用户展示进度。普通浏览器调用方不传任何选项。默认使用预注入的页面传输,除非提供 `seams` 覆盖:当 `globalThis.__DSH_TRANSPORT__` 携带 `loadBundle` 时,模块阶段将其采纳为 bundle 传输并跳过 `immediately` 层级的 HTTP 预取,而显式 `seams` 仍然优先(例如外部 `<script>` 执行无法到达页面上下文的 jsdom 测试)。
|
||||
组装浏览器应用时使用它:`apps/web` 的 Vite 入口对挂载点运行 `new AppWebEntry(container).run()`,启动页会在激活过程中向用户展示进度。普通浏览器调用方不传任何选项。默认使用预注入的页面传输,除非提供 `seams` 覆盖:当 `globalThis.__DSH_TRANSPORT__` 携带 `loadBundle` 时,模块阶段将其采纳为 bundle 传输并跳过 `immediately` 层级的 HTTP 预取;携带 `resolveAsset` 时,模块阶段将其采纳为解析器,把每个 `require.asset` 包资源引用映射为页面可加载的 URL;而显式 `seams` 仍然优先(例如外部 `<script>` 执行无法到达页面上下文的 jsdom 测试)。
|
||||
|
||||
静态应用页面在入口运行前安装 `__DSH_BOOT_READY__`。`run()` 等待期间会立即显示启动页;页面所有者通过 `applyIndexInjections`(也从 `./injections` 导出)应用 Host 注入项,并在所有脚本完成后兑现延迟对象。延迟对象拒绝时显示启动失败;若调用方提供 `run(onFailure)`,则由外部呈现错误并保留加载页。Desktop 使用该回调请求原生恢复。Desktop 与 WebWorker 共享注入解释器;服务端 `tapIndex` HTML 转换仅适用于服务端提供的文档。
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import { installWindowDragRecall } from './window-drag/recall.ts'
|
||||
import './base.css'
|
||||
|
||||
/** Module transport hook replaced by jsdom tests. */
|
||||
export type BootSeams = Pick<ClientModuleCreateOptions, 'loadBundle'>
|
||||
export type BootSeams = Pick<ClientModuleCreateOptions, 'loadBundle' | 'resolveAsset'>
|
||||
|
||||
/** Browser boot entry consumed by `apps/web`. */
|
||||
export class AppWebEntry {
|
||||
@@ -60,18 +60,19 @@ export class AppWebEntry {
|
||||
if (moduleLoader === undefined) {
|
||||
throw new Error('web boot: window.__ModuleLoader__ bootstrap facade is missing')
|
||||
}
|
||||
// A pre-injected transport (the worker preview page) owns bundle bytes;
|
||||
// its loadBundle is the default and explicit seams still win. The global
|
||||
// A pre-injected transport (the worker preview page) owns bundle and asset
|
||||
// bytes; its loadBundle and resolveAsset are the defaults and explicit seams still win. The global
|
||||
// is `ClientTransportHooks`, owned by @deepseek-ai/dsh-client-connection;
|
||||
// this structural slice reads one optional member without adding a
|
||||
// this structural slice reads two optional members without adding a
|
||||
// package edge.
|
||||
const transport = (globalThis as {
|
||||
__DSH_TRANSPORT__?: { loadBundle?: ClientModuleCreateOptions['loadBundle'] }
|
||||
__DSH_TRANSPORT__?: Pick<ClientModuleCreateOptions, 'loadBundle' | 'resolveAsset'>
|
||||
}).__DSH_TRANSPORT__
|
||||
this.modules = moduleLoader.create({
|
||||
boot: win.__DSH_BOOT__,
|
||||
staticModules: getStaticModules(),
|
||||
...transport?.loadBundle === undefined ? {} : { loadBundle: transport.loadBundle },
|
||||
...transport?.resolveAsset === undefined ? {} : { resolveAsset: transport.resolveAsset },
|
||||
...this.seams,
|
||||
})
|
||||
this.manifest = this.modules.manifest
|
||||
|
||||
@@ -13,7 +13,7 @@ const PROVIDER_CLIENT_ID = 'provider/client'
|
||||
const RUNTIME_CLIENT_ID = 'runtime/client'
|
||||
const win = globalThis as DshWindow
|
||||
const transportGlobal = globalThis as {
|
||||
__DSH_TRANSPORT__?: { loadBundle(url: string): Promise<void> }
|
||||
__DSH_TRANSPORT__?: { loadBundle(url: string): Promise<void>; resolveAsset?(reference: string): string }
|
||||
}
|
||||
const moduleFace = modulesClient as Record<string, unknown>
|
||||
|
||||
@@ -166,12 +166,14 @@ describe('plugin activation', () => {
|
||||
factory: require => ({
|
||||
apply: () => {
|
||||
expect((require(RUNTIME_CLIENT_ID) as { marker: string }).marker).toBe('provider')
|
||||
// The transport's asset resolver reaches every bundle factory.
|
||||
expect((require(PROVIDER_CLIENT_ID) as { asset: string }).asset).toBe('blob:plugins/provider/assets/a.svg')
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'provider',
|
||||
factory: () => ({ apply: () => {}, marker: 'provider' }),
|
||||
factory: require => ({ apply: () => {}, marker: 'provider', asset: require.asset('plugins/provider/assets/a.svg') }),
|
||||
},
|
||||
{
|
||||
id: 'runtime',
|
||||
@@ -190,6 +192,7 @@ describe('plugin activation', () => {
|
||||
},
|
||||
]
|
||||
transportGlobal.__DSH_TRANSPORT__ = {
|
||||
resolveAsset: reference => `blob:${reference}`,
|
||||
loadBundle: async (url) => {
|
||||
loaded.push(url)
|
||||
if (url !== applicationUrl) throw new Error(`missing fixture batch ${url}`)
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 4e53164159b5ce41
|
||||
zh: 89582dc0294d0776
|
||||
/deepseek-ai-dsh-experimental-webworker-runtime/use-this-package:
|
||||
en: 08ae0830179980c0
|
||||
zh: f9e4459b2fad4b04
|
||||
en: e55bf1e080a6e075
|
||||
zh: 1d3829a5b7fd2d13
|
||||
/deepseek-ai-dsh-experimental-webworker-runtime/model-experience:
|
||||
en: 61fce9b4c7dc96e6
|
||||
zh: dc2d931063a38327
|
||||
|
||||
@@ -31,7 +31,7 @@ Three build artifacts and one source-owned process layer:
|
||||
- **Lazy archives** — the manifest registers each page chunk the packer moved out of the base image by path and size, with its archive name relative to the image URL. Existence, stats, and listings answer at once; the first asynchronous read (`fs.promises.readFile`, `fs.promises.open`, `fs.createReadStream`, `fs.promises.cp`) fetches and inflates that archive once for every waiting reader, checks it against the registration, and mounts its files without publishing a mutation. A failed fetch rejects the read and leaves the files registered, so the next read fetches again. Until then, synchronous reads, writes, appends, truncation, chmod, rename, and link of a registered file fail with `ERR_DSH_VFS_DEFERRED` naming the archive; unlink and recursive removal drop the registration, and seeding the path replaces it. The archive name derives from its content digest, and the fetch checks only the registered paths and sizes, not the digest. An image that registers lazy archives must be passed as a URL.
|
||||
- **`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. `node-addon-require-builtin` answers the Node v24.18.0 internal modules that the vendored Loader, app-boot, and HMR read without `--expose-internals`: the cascaded ESM loader's `resolveSync` and `import` over the Worker loader, with Node's `ERR_MODULE_NOT_FOUND` and `ERR_PACKAGE_PATH_NOT_EXPORTED` codes, and an empty `loadCache`; and the CommonJS loader, package reader, `modules` binding, and resolve-cache members that HMR replaces, which refuse when called. `process.versions.node` reports `24.18.0`, so `ModuleLoader.fromInternal()` and the Loader's `internal` reach that cascaded loader. `node:path.win32.isAbsolute` and `node:util.inherits` follow Node; other `win32` members throw. The `node:util` JSON-data comparator compares own enumerable properties, preserving Node admission decisions for recorded fields such as `__proto__`. The global `process` shim carries Node detection fields including `title`, preventing Worker execution from entering DOM-only branches; Node’s `global` alias references the same `globalThis` object for image-loaded packages. The pack-time parser reports statically named module requests, including module-scope direct calls of the form `createRequire(import.meta.url)('pkg')` through a named `node:module` or `module` import, to the packer's reachability walk. Stored, CommonJS-obtained, and rebased `createRequire` calls require image entry seeds. VFS mutations drive `node:fs` callback, polling, and promise watchers, whose listeners run in the async context where the watcher was created rather than that of the writing code; open descriptors retain file identity and access mode across rename, replacement, and unlink, `FileHandle.stat({ bigint: true })` reports the same device and inode identity as a path stat while the name still refers to that file, and `FileHandle.chmod()` updates the opened file identity; `readable-stream` supplies `node:stream/promises` completion and pipeline helpers and the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. The maintained `string_decoder` package supplies `node:string_decoder`, retaining incomplete multibyte characters between chunks. 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. The callback `fs.realpath.native` alias resolves within the mounted VFS and supports `util.promisify`.
|
||||
- **`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`; `xdg-open` sends one VFS text file to the page's read-only viewer, so desktop-opener actions such as **Open configuration file** show the file in the page; 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` 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 worker's unsolicited `view-text` frame opens the page's modal text viewer. The tunnel also exposes fetch-shaped transport, the independent file-upload carrier, and the API client. Request frames preserve Blob bodies through structured clone and transfer `ReadableStream<Uint8Array>` ownership. The Host Worker streams both forms into the route, so neither browser thread creates a complete byte array for a generic-file upload. A Remote stream's uplink crosses as `stream-uplink-item` / `stream-uplink-end` frames into an unbounded `TunnelUplink` that drops items arriving after `end`: the page and its worker are one trusted same-origin boundary, so the Host inbox cap does not apply.
|
||||
- **`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. Before the shell entry runs, the page reads `GET /__assets__`, which the tunnel answers with an uncompressed tar of every package asset in the image, and supplies `resolveAsset`, which maps each `plugins/<package>/assets/<path>` reference a bundle passes to `require.asset` to a Blob URL of those bytes. The worker's unsolicited `view-text` frame opens the page's modal text viewer. The tunnel also exposes fetch-shaped transport, the independent file-upload carrier, and the API client. Request frames preserve Blob bodies through structured clone and transfer `ReadableStream<Uint8Array>` ownership. The Host Worker streams both forms into the route, so neither browser thread creates a complete byte array for a generic-file upload. A Remote stream's uplink crosses as `stream-uplink-item` / `stream-uplink-end` frames into an unbounded `TunnelUplink` that drops items arriving after `end`: the page and its worker are one trusted same-origin boundary, so the Host inbox cap does not apply.
|
||||
|
||||
Callbacks and timers registered under an explicit root context retain its empty stores, so they cannot inherit an unrelated transaction.
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ kind: "package-library"
|
||||
- **懒加载归档**——manifest 按路径和大小登记 packer 移出基础镜像的每个页面 chunk,并给出相对镜像 URL 的归档名。存在性、stat 和目录列表立即作答;第一次异步读取(`fs.promises.readFile`、`fs.promises.open`、`fs.createReadStream`、`fs.promises.cp`)为所有等待中的读取方只拉取并解压一次该归档,按登记内容校验后挂载其文件,不发布 mutation。拉取失败时本次读取被拒绝,文件保持登记状态,下次读取会重新拉取。在此之前,对已登记文件的同步读取、写入、追加、截断、chmod、rename 和 link 会以 `ERR_DSH_VFS_DEFERRED` 失败,错误中写明归档名;unlink 和递归删除会移除登记,再次 seed 该路径会替换登记。归档名由内容摘要派生,拉取时只校验登记的路径和大小,不校验摘要。登记了懒加载归档的镜像必须以 URL 传入。
|
||||
- **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。`node:module` 在镜像 package 根之上提供 `createRequire().resolve` 与 `.resolve.paths()`,使未修改的包无需执行目标模块即可发现 manifest。`node-addon-require-builtin` 应答 vendor Loader、app-boot 和 HMR 在没有 `--expose-internals` 时读取的 Node v24.18.0 internal 模块:cascaded ESM loader 的 `resolveSync` 和 `import` 经 Worker 加载器完成,失败时带 Node 的 `ERR_MODULE_NOT_FOUND` 与 `ERR_PACKAGE_PATH_NOT_EXPORTED` 错误码,`loadCache` 为空;HMR 会替换的 CommonJS loader、package reader、`modules` binding 与 resolve cache 成员被调用时拒绝。`process.versions.node` 报告 `24.18.0`,因此 `ModuleLoader.fromInternal()` 和 Loader 的 `internal` 拿到的是这个 cascaded loader。`node:path.win32.isAbsolute` 与 `node:util.inherits` 与 Node 一致,其余 `win32` 成员抛出。`node:util` 的 JSON 数据比较器检查可枚举的自有属性,使 `__proto__` 等记录字段的准入判定与 Node 一致。全局 `process` shim 带有包括 `title` 在内的 Node 环境识别字段,避免 Worker 执行误入仅适用于 DOM 的分支;镜像中加载的包通过 Node 的 `global` 别名访问同一个 `globalThis` 对象。pack 期解析器会把名称静态可知的模块请求报告给 packer 的可达性遍历,其中包括通过 `node:module` 或 `module` 具名导入在模块作用域直接发起的 `createRequire(import.meta.url)('pkg')` 调用。保存、经 CommonJS 获取或另设基准的 `createRequire` 调用需要镜像入口种子。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher,监听回调运行在创建 watcher 时的异步上下文中,而不是写入方代码的上下文中;打开的 descriptor 在 rename、replacement 和 unlink 后仍保留文件身份与访问模式,只要文件名仍指向该文件,`FileHandle.stat({ bigint: true })` 报告的 device 与 inode 身份就与路径 stat 相同,`FileHandle.chmod()` 则更新打开文件身份的权限;`readable-stream` 提供 `node:stream/promises` 的完成等待与管道方法,以及文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。维护中的 `string_decoder` 包提供 `node:string_decoder`,在数据块之间保留不完整的多字节字符。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒。回调式 `fs.realpath.native` 别名在已挂载的 VFS 内解析路径,并支持 `util.promisify`。
|
||||
- **`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 包协议;`xdg-open` 把一个 VFS 文本文件交给页面的只读查看器,因此 **打开配置文件** 等桌面打开操作会在页面内显示该文件;普通命令使用本包的求值器与 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` 会在首次需要时获取 combo、把仅 tunnel 可达的 sourcemap 内嵌为 Base64 data URL,再以 Blob 执行脚本。worker 主动发出的 `view-text` 帧会打开页面的模态文本查看器。Tunnel 还暴露 fetch 形式的传输、独立文件上传载体和 API 客户端。请求帧通过结构化克隆保留 Blob 请求体,并转移 `ReadableStream<Uint8Array>` 的所有权。Host Worker 将两种请求体都逐块送入路由,因此通用文件上传不会在任何浏览器线程创建完整字节数组。Remote 流的上行以 `stream-uplink-item` / `stream-uplink-end` 帧进入没有上限的 `TunnelUplink`,`end` 之后到达的项被丢弃:页面与其 worker 是同一个可信的同源边界,Host 的 inbox 上限在此不适用。
|
||||
- **`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 执行脚本。在 shell 入口运行之前,页面读取 `GET /__assets__`(隧道以镜像中所有包资源组成的未压缩 tar 回答),并提供 `resolveAsset`,把 bundle 传给 `require.asset` 的每个 `plugins/<package>/assets/<path>` 引用映射为这些字节的 Blob URL。worker 主动发出的 `view-text` 帧会打开页面的模态文本查看器。Tunnel 还暴露 fetch 形式的传输、独立文件上传载体和 API 客户端。请求帧通过结构化克隆保留 Blob 请求体,并转移 `ReadableStream<Uint8Array>` 的所有权。Host Worker 将两种请求体都逐块送入路由,因此通用文件上传不会在任何浏览器线程创建完整字节数组。Remote 流的上行以 `stream-uplink-item` / `stream-uplink-end` 帧进入没有上限的 `TunnelUplink`,`end` 之后到达的项被丢弃:页面与其 worker 是同一个可信的同源边界,Host 的 inbox 上限在此不适用。
|
||||
|
||||
在显式根上下文中登记的回调和定时器保留其空 store,避免继承无关事务。
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* The worker's unsolicited `view-text` frame opens the page's text viewer.
|
||||
*/
|
||||
|
||||
import { packageAssetResolver } from '../package-assets.ts'
|
||||
import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
|
||||
import type {
|
||||
TunnelAbortFrame as AbortFrame,
|
||||
@@ -365,6 +366,18 @@ export class WorkerTunnel {
|
||||
return await response.json() as BootPayload
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every package asset of the image and map each reference to a Blob URL.
|
||||
* @returns The synchronous resolver the module system calls for `require.asset`.
|
||||
*/
|
||||
async packageAssets(): Promise<(reference: string) => string> {
|
||||
const response = await this.fetch('/__assets__')
|
||||
if (!response.ok) {
|
||||
throw new Error(`web-preview tunnel: package assets failed with HTTP ${String(response.status)}: ${await response.text()}`)
|
||||
}
|
||||
return packageAssetResolver(new Uint8Array(await response.arrayBuffer()))
|
||||
}
|
||||
|
||||
/**
|
||||
* `loadBundle` seam: take one client bundle through the tunnel and execute it
|
||||
* as a classic script, exactly like the shell's same-origin `<script src>`.
|
||||
|
||||
@@ -34,6 +34,8 @@ interface ClientTransportGlobal {
|
||||
uplink?: AsyncIterable<unknown>,
|
||||
) => AsyncIterable<unknown>
|
||||
loadBundle: (url: string) => Promise<void>
|
||||
/** Package-asset resolver behind `require.asset`: the page cannot request asset references itself. */
|
||||
resolveAsset: (reference: string) => string
|
||||
/** The page spawned the worker the Host runs in, so the page owns it. */
|
||||
ownsHost: boolean
|
||||
}
|
||||
@@ -153,11 +155,12 @@ export async function connectWorkerHost(worker: Worker, options?: WorkerHostConn
|
||||
new URL(options?.image ?? IMAGE_FILE_NAME, document.baseURI).href,
|
||||
(options?.overlays ?? []).map(overlay => new URL(overlay, document.baseURI).href),
|
||||
)
|
||||
const payload = await tunnel.bootPayload()
|
||||
const [payload, resolveAsset] = await Promise.all([tunnel.bootPayload(), tunnel.packageAssets()])
|
||||
;(globalThis as ClientTransportGlobal).__DSH_TRANSPORT__ = {
|
||||
fetch: (input, init) => tunnel.fetch(input, init),
|
||||
openStream: (endpoint, payload, signal, uplink) => tunnel.open(endpoint, payload, signal, uplink),
|
||||
loadBundle: (url: string) => tunnel.loadBundle(url),
|
||||
resolveAsset,
|
||||
// The host lives in a worker this page spawned: the page owns it, so
|
||||
// the privileged surface stays reachable off loopback authorities.
|
||||
ownsHost: true,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Package assets for the preview page. A Client bundle names each image as a
|
||||
* document-relative reference, `plugins/<package>/assets/<path>`, and resolves
|
||||
* it through the module loader's synchronous `require.asset`. The page of the
|
||||
* Worker preview cannot request such a URL itself, so the Worker hands it every
|
||||
* asset the image holds in one archive and the page maps each reference to a
|
||||
* Blob URL before any bundle runs.
|
||||
* @module @deepseek-ai/dsh-experimental-webworker-runtime/src/package-assets
|
||||
*/
|
||||
import { join } from './module-system/posix-path.ts'
|
||||
import type { MemoryVfs } from './storage/memory.ts'
|
||||
import { packTar, parseTar } from './storage/tar.ts'
|
||||
import type { VfsDirent } from './storage/types.ts'
|
||||
|
||||
/** Media type of each asset extension the Client build emits. */
|
||||
const ASSET_MEDIA_TYPES: ReadonlyMap<string, string> = new Map([
|
||||
['.svg', 'image/svg+xml'],
|
||||
['.png', 'image/png'],
|
||||
['.webp', 'image/webp'],
|
||||
['.jpg', 'image/jpeg'],
|
||||
['.jpeg', 'image/jpeg'],
|
||||
])
|
||||
|
||||
/** Entries of one directory. */
|
||||
function entriesOf(vfs: MemoryVfs, directory: string): VfsDirent[] {
|
||||
return vfs.readdirSync(directory, { withFileTypes: true })
|
||||
}
|
||||
|
||||
/** Files under one directory, as `/`-separated paths relative to it. */
|
||||
function filesBelow(vfs: MemoryVfs, directory: string, prefix = ''): string[] {
|
||||
return entriesOf(vfs, directory).flatMap(entry => entry.isDirectory()
|
||||
? filesBelow(vfs, join(directory, entry.name), `${prefix}${entry.name}/`)
|
||||
: entry.isFile() ? [`${prefix}${entry.name}`] : [])
|
||||
}
|
||||
|
||||
/** Installed package directories under `node_modules`, scoped ones included. */
|
||||
function packageDirectories(vfs: MemoryVfs, modules: string): string[] {
|
||||
if (!vfs.existsSync(modules)) return []
|
||||
return entriesOf(vfs, modules).filter(entry => entry.isDirectory()).flatMap(entry =>
|
||||
entry.name.startsWith('@')
|
||||
? entriesOf(vfs, join(modules, entry.name)).filter(scoped => scoped.isDirectory())
|
||||
.map(scoped => join(modules, entry.name, scoped.name))
|
||||
: [join(modules, entry.name)])
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive every file a package of the image publishes through `"./assets/*": "./lib/assets/*"`.
|
||||
* @param vfs - Mounted image filesystem.
|
||||
* @param root - Image root holding `node_modules`.
|
||||
* @returns An uncompressed tar whose entries are named `plugins/<package>/assets/<path>` with the path unencoded,
|
||||
* so every name fits the ustar limits the base image already met.
|
||||
*/
|
||||
export function packPackageAssets(vfs: MemoryVfs, root: string): Uint8Array {
|
||||
const files: Record<string, Uint8Array> = {}
|
||||
for (const directory of packageDirectories(vfs, join(root, 'node_modules'))) {
|
||||
const manifestPath = join(directory, 'package.json')
|
||||
if (!vfs.existsSync(manifestPath)) continue
|
||||
const manifest = JSON.parse(vfs.readFileSync(manifestPath, 'utf8') as string) as { name?: unknown; exports?: Record<string, unknown> }
|
||||
// The export target client-modules and the Client build require of an asset-publishing package.
|
||||
if (typeof manifest.name !== 'string' || manifest.exports?.['./assets/*'] !== './lib/assets/*') continue
|
||||
const assets = join(directory, 'lib', 'assets')
|
||||
if (!vfs.existsSync(assets)) continue
|
||||
for (const path of filesBelow(vfs, assets)) {
|
||||
files[`plugins/${manifest.name}/assets/${path}`] = vfs.readFileSync(join(assets, path)) as Uint8Array
|
||||
}
|
||||
}
|
||||
return packTar(files)
|
||||
}
|
||||
|
||||
/**
|
||||
* Map each archived package asset to a Blob URL kept for the page's lifetime.
|
||||
* @param archive - Archive from {@link packPackageAssets}.
|
||||
* @returns The synchronous resolver behind `require.asset`: the Blob URL of a
|
||||
* reference the image holds, otherwise the reference itself, which then fails as an unserved image.
|
||||
*/
|
||||
export function packageAssetResolver(archive: Uint8Array): (reference: string) => string {
|
||||
const urls = new Map<string, string>()
|
||||
for (const entry of parseTar(archive)) {
|
||||
const [, name, path] = /^plugins\/((?:@[^/]+\/)?[^/]+)\/assets\/(.+)$/u.exec(entry.name) ?? []
|
||||
if (name === undefined || path === undefined) continue
|
||||
// The same per-segment encoding the Client build writes into each reference.
|
||||
const encoded = path.split('/')
|
||||
.map(segment => encodeURIComponent(segment).replace(/[!'()*]/gu, char => `%${char.charCodeAt(0).toString(16).toUpperCase()}`))
|
||||
const reference = `plugins/${name}/assets/${encoded.join('/')}`
|
||||
const extension = path.slice(path.lastIndexOf('.')).toLowerCase()
|
||||
// A copy detaches the Blob from the archive buffer the entry views.
|
||||
urls.set(reference, URL.createObjectURL(new Blob([new Uint8Array(entry.bytes)], { type: ASSET_MEDIA_TYPES.get(extension) ?? '' })))
|
||||
}
|
||||
return reference => urls.get(reference) ?? reference
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
*
|
||||
* - `GET /__boot__` answers from tunnel glue, never from the host API surface,
|
||||
* because the page needs the boot payload before its Cordis tree exists.
|
||||
* `GET /__assets__` answers the same way with every package asset of the
|
||||
* image, which the page maps before any Client bundle runs.
|
||||
* - Privileged `/api` methods take that same direct entry. The method set is not
|
||||
* restated here: a 401 or 403 from the route lane is retried on the direct
|
||||
* lane because the page owns the worker and needs no network authentication.
|
||||
@@ -91,6 +93,8 @@ export interface TunnelSeams {
|
||||
readonly directFetch: (request: Request) => Promise<Response>
|
||||
/** Boot payload for `GET /__boot__`: the structured index injection table. */
|
||||
readonly bootPayload: () => unknown
|
||||
/** Archive for `GET /__assets__`: every package asset of the image, named by its unencoded `plugins/<package>/assets/<path>`. */
|
||||
readonly packageAssets: () => Uint8Array
|
||||
/** Open one decoded Gateway Remote stream without another network carrier; `uplink` carries the page's items. */
|
||||
readonly openStream: (
|
||||
endpoint: string,
|
||||
@@ -421,6 +425,7 @@ export class TunnelServer {
|
||||
try {
|
||||
const { frame: routed, path } = this.pathFrame(frame)
|
||||
if (path === '/__boot__') { this.serveBoot(frame, sink); return }
|
||||
if (path === '/__assets__') { this.servePackageAssets(frame, sink); return }
|
||||
if (path.startsWith(`${API_PREFIX}/`)) { await this.serveApi(frame, routed, path, sink); return }
|
||||
this.dispatch(routed, sink)
|
||||
} catch (reason) {
|
||||
@@ -491,6 +496,15 @@ export class TunnelServer {
|
||||
buffered.flushTo(sink)
|
||||
}
|
||||
|
||||
private servePackageAssets(frame: TunnelRequestFrame, sink: ResponseSink): void {
|
||||
if (this.seams === undefined) throw new Error('webworker tunnel: package assets requested before the host tree is serving')
|
||||
if (frame.method !== 'GET') {
|
||||
sink.end({ status: 405, headers: { allow: 'GET' } })
|
||||
return
|
||||
}
|
||||
sink.end({ status: 200, headers: { 'content-type': 'application/x-tar', 'cache-control': 'no-store' }, body: this.seams.packageAssets() })
|
||||
}
|
||||
|
||||
private serveBoot(frame: TunnelRequestFrame, sink: ResponseSink): void {
|
||||
if (this.seams === undefined) throw new Error('webworker tunnel: boot payload requested before the host tree is serving')
|
||||
if (frame.method !== 'GET') {
|
||||
|
||||
@@ -36,6 +36,7 @@ import { inflateImage, inflateImageStream } from './storage/image-gzip.ts'
|
||||
import { loadVfsImage, loadVfsOverlay, MemoryVfs } from './storage/memory.ts'
|
||||
import { setActiveVfs } from './storage/active.ts'
|
||||
import { setTextViewer } from './shell/process/xdg-open.ts'
|
||||
import { packPackageAssets } from './package-assets.ts'
|
||||
import {
|
||||
DEFAULT_ROOT, IMAGE_EMPTY_DIRECTORIES, IMAGE_HOME_DIRECTORY, IMAGE_MANIFEST_PATH, LOWERING_VERSION,
|
||||
} from './image-layout.ts'
|
||||
@@ -273,6 +274,7 @@ export function createWorkerHost(options: WorkerHostOptions): WorkerHost {
|
||||
tunnel.serve({
|
||||
directFetch: (request: Request) => handler.fetch(request, peer),
|
||||
bootPayload: () => readBootPayload(ctx),
|
||||
packageAssets: () => packPackageAssets(mounted, root),
|
||||
openStream: (endpoint, payload, uplink, signal) =>
|
||||
typertGateway.wireStream.open(endpoint, payload, uplink, peer, signal),
|
||||
streamFailure: typertGateway.wireStream.failure,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { WorkerTunnel } from '../../src/client/client.ts'
|
||||
import { packTar } from '../../src/storage/tar.ts'
|
||||
|
||||
type StubListener = (event: { data?: unknown }) => void
|
||||
|
||||
@@ -84,3 +85,25 @@ it('loads a combo map through the tunnel and embeds it in the blob script', asyn
|
||||
expect(new TextDecoder().decode(decoded)).toBe(map)
|
||||
expect(revoked).toEqual(['blob:fixture-1'])
|
||||
})
|
||||
|
||||
it('reads the package-asset archive through the tunnel into a synchronous resolver', async () => {
|
||||
const { worker, sent, deliver } = stubWorker()
|
||||
const tunnel = new WorkerTunnel(worker)
|
||||
const NativeURL = URL
|
||||
class StubURL extends NativeURL {
|
||||
static override createObjectURL(): string { return 'blob:fixture-asset' }
|
||||
}
|
||||
vi.stubGlobal('URL', StubURL)
|
||||
const reference = 'plugins/@fixture/ui/assets/mark-0123456789ab.svg'
|
||||
|
||||
const reading = tunnel.packageAssets()
|
||||
expect(sent[0]?.url).toBe('http://localhost:3000/__assets__')
|
||||
deliver({ t: 'res', id: sent[0]!.id, status: 200, headers: { 'content-type': 'application/x-tar' }, body: packTar({ [reference]: new TextEncoder().encode('<svg/>') }).slice().buffer })
|
||||
const resolve = await reading
|
||||
expect(resolve(reference)).toBe('blob:fixture-asset')
|
||||
expect(resolve('plugins/other/assets/a.svg')).toBe('plugins/other/assets/a.svg')
|
||||
|
||||
const failing = tunnel.packageAssets()
|
||||
deliver({ t: 'res', id: sent[1]!.id, status: 503, headers: { 'content-type': 'text/plain' }, body: new TextEncoder().encode('host failed').buffer })
|
||||
await expect(failing).rejects.toThrow('web-preview tunnel: package assets failed with HTTP 503: host failed')
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
PACKAGE_ASSET_EXPORT, PACKAGE_ASSET_EXPORT_TARGET, PACKAGE_ASSET_MEDIA_TYPES, packageAssetReference,
|
||||
} from '@deepseek-ai/dsh-client-modules/src/assets.ts'
|
||||
import { packPackageAssets, packageAssetResolver } from '../src/package-assets.ts'
|
||||
import { MemoryVfs } from '../src/storage/memory.ts'
|
||||
import { parseTar } from '../src/storage/tar.ts'
|
||||
|
||||
const ROOT = '/dsh'
|
||||
|
||||
afterEach(() => { vi.unstubAllGlobals() })
|
||||
|
||||
/** Seed one installed package into the image filesystem. */
|
||||
function seedPackage(vfs: MemoryVfs, name: string, exports: Record<string, string> | undefined, files: Record<string, string>): void {
|
||||
const directory = `${ROOT}/node_modules/${name}`
|
||||
vfs.seed(`${directory}/package.json`, JSON.stringify({ name, ...exports === undefined ? {} : { exports } }))
|
||||
for (const [path, body] of Object.entries(files)) vfs.seed(`${directory}/${path}`, body)
|
||||
}
|
||||
|
||||
it('archives every file of the packages that publish ./assets/* under its unencoded page path', () => {
|
||||
const vfs = new MemoryVfs()
|
||||
seedPackage(vfs, '@fixture/ui', { './client': './lib/client.js', [PACKAGE_ASSET_EXPORT]: PACKAGE_ASSET_EXPORT_TARGET }, {
|
||||
'lib/client.js': '',
|
||||
'lib/assets/mark-0123456789ab.svg': '<svg/>',
|
||||
'lib/assets/icons/whale@2x-0123456789ab.png': 'png',
|
||||
})
|
||||
seedPackage(vfs, 'unexported', { '.': './lib/index.js' }, { 'lib/assets/own.svg': '<svg/>' })
|
||||
seedPackage(vfs, 'plain', undefined, { 'lib/index.js': '' })
|
||||
vfs.seed(`${ROOT}/node_modules/@fixture/stray.txt`, 'not a package')
|
||||
|
||||
const entries = parseTar(packPackageAssets(vfs, ROOT))
|
||||
|
||||
expect(Object.fromEntries(entries.map(entry => [entry.name, new TextDecoder().decode(entry.bytes)]))).toEqual({
|
||||
'plugins/@fixture/ui/assets/mark-0123456789ab.svg': '<svg/>',
|
||||
'plugins/@fixture/ui/assets/icons/whale@2x-0123456789ab.png': 'png',
|
||||
})
|
||||
expect(parseTar(packPackageAssets(new MemoryVfs(), ROOT))).toEqual([])
|
||||
})
|
||||
|
||||
it('maps each archived file to a Blob URL under the reference and media type the Client build and route use', async () => {
|
||||
const vfs = new MemoryVfs()
|
||||
const long = `${'汉'.repeat(10)}-0123456789ab.png`
|
||||
seedPackage(vfs, '@fixture/ui', { [PACKAGE_ASSET_EXPORT]: PACKAGE_ASSET_EXPORT_TARGET }, {
|
||||
'lib/assets/mark-0123456789ab.svg': '<svg/>',
|
||||
'lib/assets/data-0123456789ab.bin': 'raw',
|
||||
[`lib/assets/${long}`]: 'png',
|
||||
"lib/assets/whale(2x)!'*-0123456789ab.png": 'special',
|
||||
...Object.fromEntries([...PACKAGE_ASSET_MEDIA_TYPES.keys()].map(extension => [`lib/assets/types/a${extension}`, extension])),
|
||||
})
|
||||
const blobs: Blob[] = []
|
||||
const NativeURL = URL
|
||||
class StubURL extends NativeURL {
|
||||
static override createObjectURL(blob: Blob): string {
|
||||
blobs.push(blob)
|
||||
return `blob:fixture-${String(blobs.length)}`
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('URL', StubURL)
|
||||
|
||||
const resolve = packageAssetResolver(packPackageAssets(vfs, ROOT))
|
||||
const blobOf = (url: string): Blob | undefined => blobs[Number(url.slice('blob:fixture-'.length)) - 1]
|
||||
|
||||
for (const [extension, type] of PACKAGE_ASSET_MEDIA_TYPES) {
|
||||
expect(blobOf(resolve(packageAssetReference('@fixture/ui', `types/a${extension}`)))?.type).toBe(type)
|
||||
}
|
||||
expect(await blobOf(resolve(packageAssetReference('@fixture/ui', "whale(2x)!'*-0123456789ab.png")))?.text()).toBe('special')
|
||||
// A name whose encoded reference exceeds a ustar name segment still archives and resolves.
|
||||
expect(await blobOf(resolve(packageAssetReference('@fixture/ui', long)))?.text()).toBe('png')
|
||||
const svg = resolve('plugins/@fixture/ui/assets/mark-0123456789ab.svg')
|
||||
const svgBlob = blobs[Number(svg.slice('blob:fixture-'.length)) - 1]
|
||||
expect(svgBlob?.type).toBe('image/svg+xml')
|
||||
expect(await svgBlob?.text()).toBe('<svg/>')
|
||||
const raw = resolve('plugins/@fixture/ui/assets/data-0123456789ab.bin')
|
||||
expect(blobs[Number(raw.slice('blob:fixture-'.length)) - 1]?.type).toBe('')
|
||||
expect(resolve('plugins/@fixture/ui/assets/missing.svg')).toBe('plugins/@fixture/ui/assets/missing.svg')
|
||||
})
|
||||
@@ -21,6 +21,7 @@ function seams(openStream: TunnelSeams['openStream']): TunnelSeams {
|
||||
return {
|
||||
directFetch: () => Promise.reject(new Error('fixture has no direct fetch')),
|
||||
bootPayload: () => ({}),
|
||||
packageAssets: () => bytes(1, 2, 3),
|
||||
openStream,
|
||||
streamFailure: error => ({
|
||||
code: 'fixture-stream-failed',
|
||||
@@ -66,6 +67,22 @@ describe('worker tunnel unary authentication', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('worker tunnel package assets', () => {
|
||||
it('answers GET /__assets__ with the package-asset archive and refuses other methods', async () => {
|
||||
const { server, frames } = harness()
|
||||
server.serve(seams(async () => (async function *(): AsyncGenerator { yield undefined })()))
|
||||
|
||||
server.handleMessage({ t: 'req', id: 1, method: 'GET', url: 'http://localhost/__assets__', headers: {} })
|
||||
server.handleMessage({ t: 'req', id: 2, method: 'POST', url: 'http://localhost/__assets__', headers: {} })
|
||||
|
||||
await vi.waitFor(() => { expect(frames).toHaveLength(2) })
|
||||
expect(frames[0]).toMatchObject({ t: 'res', id: 1, status: 200, headers: { 'content-type': 'application/x-tar', 'cache-control': 'no-store' } })
|
||||
if (frames[0]?.t !== 'res' || frames[0].body === undefined) throw new Error('archive response carried no body')
|
||||
expect([...new Uint8Array(frames[0].body)]).toEqual([1, 2, 3])
|
||||
expect(frames[1]).toMatchObject({ t: 'res', id: 2, status: 405, headers: { allow: 'GET' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('worker tunnel Blob requests', () => {
|
||||
it('streams an opaque Blob in bounded chunks inside the Host Worker route', async () => {
|
||||
const frames: TunnelOutboundFrame[] = []
|
||||
|
||||
@@ -601,8 +601,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
key: 'clientModules',
|
||||
summary: 'The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index injection rows.',
|
||||
description: 'The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index injection rows. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).',
|
||||
summary: 'The web plugin table service: incremental `dsh.client` scan + wire composition + bundle and package-asset route + index injection rows.',
|
||||
description: 'The web plugin table service: incremental `dsh.client` scan + wire composition + bundle and package-asset route + index injection rows. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).',
|
||||
methods: [
|
||||
{
|
||||
signature: 'graph(): WebBootGraph',
|
||||
@@ -618,7 +618,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
signature: 'async fetchBundle(request: Request): Promise<Response>',
|
||||
description: 'Serve an advertised revisioned bundle or source map without a Web server. Unknown URLs return 404, unsupported methods return 405, and `HEAD` returns the same immutable headers without materializing a body. Each body is built once on its first `GET`; script construction never reads maps.',
|
||||
description: 'Serve an advertised revisioned bundle, source map, chunk, or package asset without a Web server. Unknown URLs return 404, unsupported methods return 405, and `HEAD` returns the same immutable headers without materializing a body. Each bundle body is built once on its first `GET`; script construction never reads maps. A package asset is read on every request and answers 304 without a body when `If-None-Match` equals its entity tag; its body holds the file open until the caller consumes or cancels it.',
|
||||
parameters: [{ name: 'request', description: 'shell-carrier request for a `/plugins` resource.' }],
|
||||
returns: 'the exact response also exposed by the optional Web route.',
|
||||
},
|
||||
@@ -630,13 +630,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
signature: 'rebuilt(id: string): string | undefined',
|
||||
description: 'Publish one completed bundle generation (the HMR watch\'s registration hook — the only entry point through which build changes reach the graph). Unchanged mtime, ctime and size preserve the graph without reading the bundle.',
|
||||
description: 'Publish one completed bundle generation (the HMR watch\'s registration hook; an entry restart publishes through reconciliation instead). Unchanged mtime, ctime and size preserve the graph without reading the bundle.',
|
||||
parameters: [{ name: 'id', description: 'entry id (package name).' }],
|
||||
returns: 'the current artifact rev, or undefined for an unknown id.',
|
||||
},
|
||||
{
|
||||
signature: 'onRebuilt(listener: (id: string, rev: string) => void): () => void',
|
||||
description: 'Subscribe to bundle rebuilds; fires only when artifact metadata changes the rev.',
|
||||
description: 'Subscribe to bundle rebuilds; fires when rebuilt changes a rev. A rev an entry restart publishes reaches only onGraphChanged listeners.',
|
||||
parameters: [{ name: 'listener', description: 'receives the entry id and its new bundle rev.' }],
|
||||
returns: 'the unsubscriber.',
|
||||
},
|
||||
|
||||
@@ -442,6 +442,67 @@ describe('package payload constraints', () => {
|
||||
const manifest = JSON.parse(readFileSync(new URL(`../${dir}/package.json`, import.meta.url), 'utf8')) as WorkspaceManifest['manifest']
|
||||
expect(checkWorkspaceManifest({ dir, manifest })).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ './assets/*': './lib/assets/*' },
|
||||
{ './assets/*': { default: './lib/assets/*' } },
|
||||
])('publishes built package assets after the browser Client entries: %j', (assets) => {
|
||||
expect(expectedDshPackageFiles({
|
||||
exports: {
|
||||
'./client': { types: './lib/types/client/index.d.ts', node: './lib/client.node.js', default: './lib/client.js' },
|
||||
'./loader': './lib/loader.js',
|
||||
...assets,
|
||||
},
|
||||
})).toEqual([
|
||||
'lib/index.js', 'lib/client.js', 'lib/client.node.js', 'lib/assets', 'lib/loader.js', 'lib/types/**/*.d.ts',
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
{},
|
||||
{ './assets/*': './assets/*' },
|
||||
{ './assets/*': { types: './lib/assets/*' } },
|
||||
{ './assets/extra': './lib/assets/*' },
|
||||
])('does not publish package assets without the exact asset export target: %j', (assets) => {
|
||||
expect(expectedDshPackageFiles({ exports: { './client': './lib/client.js', ...assets } }))
|
||||
.toEqual(['lib/index.js', 'lib/client.js', 'lib/types/**/*.d.ts'])
|
||||
})
|
||||
|
||||
it('rejects a package-asset export whose payload omits the built assets', () => {
|
||||
const dir = 'packages/client/probe'
|
||||
const manifest = {
|
||||
name: '@deepseek-ai/dsh-client-probe',
|
||||
type: 'module',
|
||||
main: 'lib/index.js',
|
||||
types: 'lib/types/index.d.ts',
|
||||
exports: {
|
||||
'.': { types: './lib/types/index.d.ts', default: './lib/index.js' },
|
||||
'./client': { types: './lib/types/client/index.d.ts', default: './lib/client.js' },
|
||||
'./assets/*': './lib/assets/*',
|
||||
},
|
||||
files: ['lib/index.js', 'lib/client.js', 'lib/assets', 'lib/types/**/*.d.ts'],
|
||||
peerDependencies: { '@deepseek-ai/cordis': 'workspace:~' },
|
||||
devDependencies: { '@deepseek-ai/cordis': 'workspace:~' },
|
||||
} satisfies WorkspaceManifest['manifest']
|
||||
const payloadErrors = (candidate: WorkspaceManifest['manifest']): string[] =>
|
||||
checkWorkspaceManifest({ dir, manifest: candidate }).filter(error => error.includes('package.json files must be'))
|
||||
expect(payloadErrors(manifest)).toEqual([])
|
||||
expect(payloadErrors({ ...manifest, files: manifest.files.filter(file => file !== 'lib/assets') })).toEqual([
|
||||
expect.stringContaining('@deepseek-ai/dsh-client-probe: package.json files must be '
|
||||
+ '["lib/index.js","lib/client.js","lib/assets","lib/types/**/*.d.ts"]'),
|
||||
])
|
||||
})
|
||||
|
||||
it.each(['ui-chat', 'ui-settings-account'])(
|
||||
'accepts the published package assets for %s and rejects their omission', (name) => {
|
||||
const dir = `packages/client/${name}`
|
||||
const manifest = JSON.parse(readFileSync(new URL(`../${dir}/package.json`, import.meta.url), 'utf8')) as WorkspaceManifest['manifest']
|
||||
expect(checkWorkspaceManifest({ dir, manifest })).toEqual([])
|
||||
expect(checkWorkspaceManifest({ dir, manifest: {
|
||||
...manifest, files: manifest.files!.filter(file => file !== 'lib/assets'),
|
||||
} })).toEqual([expect.stringContaining('package.json files must be')])
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('publishes CLI runtime declarations and rejects a payload that omits them', () => {
|
||||
|
||||
@@ -21,6 +21,11 @@ import { hasTypertRemoteNavigation, isForbiddenPublicationFile } from './publica
|
||||
import type { DshBundleManifest } from '../packages/util/package-manifest/src/types.ts'
|
||||
import { detailPath } from '../packages/util/plugin-detail/src/document.ts'
|
||||
import { OPTIONAL_BUNDLES, bundlePatchFiles } from '../packages/boot/app-boot/src/profile.ts'
|
||||
import {
|
||||
PACKAGE_ASSET_EXPORT,
|
||||
PACKAGE_ASSET_EXPORT_TARGET,
|
||||
PACKAGE_ASSET_FILES_ENTRY,
|
||||
} from '../packages/client/modules/src/assets.ts'
|
||||
import { collectProjectReferenceFaceViolations } from './project-reference-faces.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
@@ -285,10 +290,11 @@ export function detailDocumentFiles(packageDir: string): readonly string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute canonical publication patterns, including declared and exported icon paths, exported locale JSON resources, and detail documents.
|
||||
* Compute canonical publication patterns, including declared and exported icon paths, exported locale JSON resources,
|
||||
* detail documents, and the built package-asset directory when `exports["./assets/*"]` targets it.
|
||||
* @param manifest - workspace package manifest.
|
||||
* @param detailFiles - entries from {@link detailDocumentFiles}.
|
||||
* @returns the icon, deduplicated locale targets, and detail documents followed by runtime and declaration payloads.
|
||||
* @returns the icon, deduplicated locale targets, and detail documents followed by runtime, package-asset, and declaration payloads.
|
||||
*/
|
||||
export function expectedDshPackageFiles(manifest: PackageManifest, detailFiles: readonly string[] = []): readonly string[] {
|
||||
const localeFiles = new Set<string>()
|
||||
@@ -327,6 +333,7 @@ export function expectedDshPackageFiles(manifest: PackageManifest, detailFiles:
|
||||
...browserClient === './lib/client.js' ? ['lib/client.js'] : [],
|
||||
...browserClient === './lib/client.browser.js' ? ['lib/client.browser.js'] : [],
|
||||
...exportCondition(manifest, './client', 'node') === './lib/client.node.js' ? ['lib/client.node.js'] : [],
|
||||
...exportDefault(manifest, PACKAGE_ASSET_EXPORT) === PACKAGE_ASSET_EXPORT_TARGET ? [PACKAGE_ASSET_FILES_ENTRY] : [],
|
||||
// runtime's shell-held loader subpath ships as its own bundle beside the client half.
|
||||
...exportDefault(manifest, './loader') === './lib/loader.js' ? ['lib/loader.js'] : [],
|
||||
// A store subpath ships its own bundle (single-entry builds; no shared chunk).
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* The type roots declare one wildcard image module per supported package
|
||||
* asset extension, each with a string default export.
|
||||
*/
|
||||
import { join } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { PACKAGE_ASSET_MEDIA_TYPES } from '../packages/client/modules/src/assets.ts'
|
||||
|
||||
const DECLARATIONS = join(import.meta.dirname, 'types/client-assets/index.d.ts')
|
||||
|
||||
describe('client asset type declarations', () => {
|
||||
it('declare a string default export for exactly the supported extensions', () => {
|
||||
const program = ts.createProgram({
|
||||
rootNames: [DECLARATIONS],
|
||||
options: { noEmit: true, noLib: true, types: [] },
|
||||
})
|
||||
const checker = program.getTypeChecker()
|
||||
const declarations = program.getSourceFile(DECLARATIONS)
|
||||
if (declarations === undefined) throw new Error(`${DECLARATIONS} is not in the program`)
|
||||
const modules = checker.getAmbientModules()
|
||||
.filter(module => module.declarations?.some(declaration => declaration.getSourceFile() === declarations))
|
||||
|
||||
expect(modules.map(module => JSON.parse(module.getName()) as string).sort())
|
||||
.toEqual([...PACKAGE_ASSET_MEDIA_TYPES.keys()].map(extension => `*${extension}`).sort())
|
||||
for (const module of modules) {
|
||||
const defaultExport = checker.getExportsOfModule(module).find(symbol => symbol.getName() === 'default')
|
||||
if (defaultExport === undefined) throw new Error(`${module.getName()} declares no default export`)
|
||||
expect(checker.typeToString(checker.getTypeOfSymbol(defaultExport)), module.getName()).toBe('string')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -38,6 +38,7 @@ const CLIENT_ARTIFACT_PATTERNS = [
|
||||
'packages/*/*/lib/client.js.map',
|
||||
'packages/*/*/lib/client.*.js',
|
||||
'packages/*/*/lib/client.*.js.map',
|
||||
'packages/*/*/lib/assets/**/*',
|
||||
] as const
|
||||
|
||||
/** Public values embedded in one set of client artifacts. */
|
||||
|
||||
@@ -1,124 +1,546 @@
|
||||
/**
|
||||
* Stylesheets and images enter client bundles through plugin loaders, which
|
||||
* must register their physical files as watch dependencies.
|
||||
* Stylesheets and package assets enter client bundles through plugin loaders,
|
||||
* which register their physical files as watch dependencies. Package assets
|
||||
* are emitted under `lib/assets/` with revisioned file names and referenced by document-relative URLs.
|
||||
*/
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { transform } from 'lightningcss'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { build, type TsdownBundle, type UserConfig } from 'tsdown'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { packageAssetRevision } from '../packages/client/modules/src/assets.ts'
|
||||
import { clientBundle } from '../packages/client/tsdown.client.ts'
|
||||
|
||||
interface EmittedAsset {
|
||||
type: 'asset'
|
||||
fileName: string
|
||||
source: Uint8Array
|
||||
originalFileName: string
|
||||
}
|
||||
|
||||
interface PluginContext {
|
||||
emitFile(file: EmittedAsset): string
|
||||
addWatchFile(id: string): void
|
||||
}
|
||||
|
||||
interface BundlePlugin {
|
||||
name: string
|
||||
buildStart?: (this: PluginContext) => void
|
||||
resolveId?: (source: string, importer?: string) => string | null
|
||||
load?: (this: { addWatchFile(id: string): void }, id: string) => Promise<string | null>
|
||||
load?: (this: PluginContext, id: string) => string | null | Promise<string | null>
|
||||
writeBundle?: (this: { meta: { watchMode: boolean } }, outputOptions: { dir?: string }, bundle: Record<string, unknown>) => Promise<void>
|
||||
renderChunk?: (code: string, chunk: unknown, outputOptions: { format: string }) => { toString(): string } | null
|
||||
}
|
||||
|
||||
function bundlePlugin(name: 'dsh-css-modules-inline' | 'dsh-css-global-inline' | 'dsh-css-text-inline' | 'dsh-image-inline'): BundlePlugin {
|
||||
const configs = clientBundle(
|
||||
join(import.meta.dirname, '../packages/client/ui-sidebar'),
|
||||
['lib/types/index.js'],
|
||||
)({ env: { DSH_BUILD_FACE: 'client' } })
|
||||
const client = configs.find(config => config.platform === 'browser')
|
||||
if (client === undefined) throw new Error('client config missing')
|
||||
const plugins = (client as { plugins: BundlePlugin[] }).plugins
|
||||
const plugin = plugins.find(candidate => candidate.name === name)
|
||||
if (plugin === undefined) throw new Error(`${name} missing from client config`)
|
||||
type PluginName = 'dsh-css-modules-inline' | 'dsh-css-global-inline' | 'dsh-css-text-inline' | 'dsh-package-asset'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-asset-fixture'
|
||||
const ASSET_EXPORTS = { './client': './lib/client.js', './assets/*': './lib/assets/*' }
|
||||
const WHALE = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3])
|
||||
const WHALE_REV = packageAssetRevision(WHALE)
|
||||
const WHALE_URL = `plugins/@deepseek-ai/dsh-client-asset-fixture/assets/whale-${WHALE_REV}.png`
|
||||
|
||||
/** The resolver call identifier a compiled module emits before rendering. */
|
||||
const RESOLVER_CALL = /__dshPackageAsset_[0-9a-f]{12}(?=\()/u
|
||||
|
||||
/** Read the resolver call identifier from one compiled module. */
|
||||
function resolverCall(output: string | null): string {
|
||||
const call = RESOLVER_CALL.exec(output ?? '')?.[0]
|
||||
if (call === undefined) throw new Error('compiled module carries no resolver call')
|
||||
return call
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate the stylesheet text an emitted style module or inline-text module computes.
|
||||
* @param output - emitted module source.
|
||||
* @param resolve - stands in for the module loader's asset resolver.
|
||||
* @returns the stylesheet text with each resolved reference.
|
||||
*/
|
||||
function stylesheetText(output: string | null, resolve: (reference: string) => string): string {
|
||||
if (output === null) throw new Error('stylesheet module was not emitted')
|
||||
const call = RESOLVER_CALL.exec(output)?.[0]
|
||||
const body = output
|
||||
.replace(/^export default (.*);$/mu, 'return typeof css === "undefined" ? $1 : css;')
|
||||
.replace(/^export \{\};$/mu, 'return css;')
|
||||
return runInNewContext(`(() => {\n${body}\n})()`, { ...call === undefined ? {} : { [call]: resolve }, document: undefined }) as string
|
||||
}
|
||||
|
||||
const temporaryRoots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryRoots.splice(0).map(root => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
/** Create a spec-owned temporary directory, addressed by its real path. */
|
||||
async function temporaryDirectory(prefix: string): Promise<string> {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), prefix)))
|
||||
temporaryRoots.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
async function writeFiles(root: string, files: Readonly<Record<string, string | Uint8Array>>): Promise<void> {
|
||||
for (const [path, content] of Object.entries(files)) {
|
||||
await mkdir(dirname(join(root, path)), { recursive: true })
|
||||
await writeFile(join(root, path), content)
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a client package fixture whose manifest declares the given exports. */
|
||||
async function assetPackage(
|
||||
files: Readonly<Record<string, string | Uint8Array>>,
|
||||
exports: Readonly<Record<string, string>> = ASSET_EXPORTS,
|
||||
): Promise<string> {
|
||||
const root = await temporaryDirectory('dsh-client-asset-')
|
||||
await writeFiles(root, { 'package.json': JSON.stringify({ name: PACKAGE_NAME, type: 'module', exports }), ...files })
|
||||
return root
|
||||
}
|
||||
|
||||
/** Client-face configs of one package, as its tsdown.config.ts would return them. */
|
||||
function clientFaceConfigs(packageDir: string, dualClient = false): UserConfig[] {
|
||||
return clientBundle(packageDir, ['lib/types/index.js'], { dualClient })({ env: { DSH_BUILD_FACE: 'client' } })
|
||||
}
|
||||
|
||||
function configPlugin(config: UserConfig | undefined, name: string): BundlePlugin {
|
||||
if (config === undefined) throw new Error('config missing')
|
||||
const plugin = (config as { plugins: BundlePlugin[] }).plugins.find(candidate => candidate.name === name)
|
||||
if (plugin === undefined) throw new Error(`${name} missing from ${String(config.name)}`)
|
||||
return plugin
|
||||
}
|
||||
|
||||
/** Look up plugins of one browser config, so stylesheet and asset plugins share its per-build state. */
|
||||
function browserPlugins(packageDir: string): (name: PluginName) => BundlePlugin {
|
||||
const client = clientFaceConfigs(packageDir).find(config => config.platform === 'browser')
|
||||
return name => configPlugin(client, name)
|
||||
}
|
||||
|
||||
function bundlePlugin(name: PluginName): BundlePlugin {
|
||||
return browserPlugins(join(import.meta.dirname, '../packages/client/ui-sidebar'))(name)
|
||||
}
|
||||
|
||||
interface RecordingContext extends PluginContext {
|
||||
readonly emitted: EmittedAsset[]
|
||||
readonly watched: string[]
|
||||
}
|
||||
|
||||
function recordingContext(): RecordingContext {
|
||||
const emitted: EmittedAsset[] = []
|
||||
const watched: string[] = []
|
||||
return {
|
||||
emitted,
|
||||
watched,
|
||||
emitFile(file) {
|
||||
emitted.push(file)
|
||||
return file.fileName
|
||||
},
|
||||
addWatchFile(id) { watched.push(id) },
|
||||
}
|
||||
}
|
||||
|
||||
/** Emitted files with hex sources, so Buffer and Uint8Array bytes compare equal. */
|
||||
function emittedFiles(context: { readonly emitted: readonly EmittedAsset[] }): object[] {
|
||||
return context.emitted.map(file => ({ ...file, source: Buffer.from(file.source).toString('hex') }))
|
||||
}
|
||||
|
||||
function hooks(plugin: BundlePlugin): Required<Pick<BundlePlugin, 'resolveId' | 'load'>> {
|
||||
if (plugin.resolveId === undefined || plugin.load === undefined) throw new Error(`${plugin.name} hooks are incomplete`)
|
||||
return { resolveId: plugin.resolveId, load: plugin.load }
|
||||
}
|
||||
|
||||
/** Resolve and load one import through a plugin. */
|
||||
async function loadImport(plugin: BundlePlugin, context: PluginContext, source: string, importer: string): Promise<string | null> {
|
||||
const { resolveId, load } = hooks(plugin)
|
||||
const id = resolveId(source, importer)
|
||||
if (typeof id !== 'string') throw new Error(`${plugin.name} did not resolve ${source}`)
|
||||
return await load.call(context, id)
|
||||
}
|
||||
|
||||
describe('client bundle CSS Modules', () => {
|
||||
it('registers the source stylesheet as a watch dependency', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-client-css-watch-'))
|
||||
try {
|
||||
const stylesheet = join(root, 'Fixture.module.css')
|
||||
const importer = join(root, 'index.ts')
|
||||
await writeFile(stylesheet, '.root { color: red; }\n')
|
||||
const plugin = bundlePlugin('dsh-css-modules-inline')
|
||||
const virtualId = plugin.resolveId?.('./Fixture.module.css', importer)
|
||||
if (typeof virtualId !== 'string' || plugin.load === undefined) {
|
||||
throw new Error('CSS Modules plugin hooks are incomplete')
|
||||
}
|
||||
const watched: string[] = []
|
||||
const root = await temporaryDirectory('dsh-client-css-watch-')
|
||||
const stylesheet = join(root, 'Fixture.module.css')
|
||||
await writeFile(stylesheet, '.root { color: red; }\n')
|
||||
const context = recordingContext()
|
||||
|
||||
const output = await plugin.load.call({ addWatchFile: id => watched.push(id) }, virtualId)
|
||||
const output = await loadImport(bundlePlugin('dsh-css-modules-inline'), context, './Fixture.module.css', join(root, 'index.ts'))
|
||||
|
||||
expect(watched).toEqual([stylesheet])
|
||||
expect(output).toContain('data-plugin-css')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
expect(context.watched).toEqual([stylesheet])
|
||||
expect(output).toContain('data-plugin-css')
|
||||
})
|
||||
})
|
||||
|
||||
describe('client bundle global CSS', () => {
|
||||
it('compiles a side-effect stylesheet into a watched style injector', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-client-global-css-watch-'))
|
||||
try {
|
||||
const stylesheet = join(root, 'base.css')
|
||||
const importer = join(root, 'index.ts')
|
||||
await writeFile(stylesheet, 'body { color: red; }\n')
|
||||
const plugin = bundlePlugin('dsh-css-global-inline')
|
||||
const virtualId = plugin.resolveId?.('./base.css', importer)
|
||||
if (typeof virtualId !== 'string' || plugin.load === undefined) {
|
||||
throw new Error('global CSS plugin hooks are incomplete')
|
||||
}
|
||||
const watched: string[] = []
|
||||
const root = await temporaryDirectory('dsh-client-global-css-watch-')
|
||||
const stylesheet = join(root, 'base.css')
|
||||
await writeFile(stylesheet, 'body { color: red; }\n')
|
||||
const context = recordingContext()
|
||||
|
||||
const output = await plugin.load.call({ addWatchFile: id => watched.push(id) }, virtualId)
|
||||
const output = await loadImport(bundlePlugin('dsh-css-global-inline'), context, './base.css', join(root, 'index.ts'))
|
||||
|
||||
expect(watched).toEqual([stylesheet])
|
||||
expect(output).toContain('data-plugin-css')
|
||||
expect(output).toContain('body{color:red}')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
expect(context.watched).toEqual([stylesheet])
|
||||
expect(output).toContain('data-plugin-css')
|
||||
expect(output).toContain('body{color:red}')
|
||||
})
|
||||
|
||||
it('compiles inline stylesheets as watched text without a module side effect', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-client-inline-css-watch-'))
|
||||
try {
|
||||
const stylesheet = join(root, 'base.css')
|
||||
const importer = join(root, 'index.ts')
|
||||
await writeFile(stylesheet, 'body { color: red; }\n')
|
||||
const plugin = bundlePlugin('dsh-css-text-inline')
|
||||
const virtualId = plugin.resolveId?.('./base.css?inline', importer)
|
||||
if (typeof virtualId !== 'string' || plugin.load === undefined) {
|
||||
throw new Error('inline CSS plugin hooks are incomplete')
|
||||
}
|
||||
const watched: string[] = []
|
||||
const root = await temporaryDirectory('dsh-client-inline-css-watch-')
|
||||
const stylesheet = join(root, 'base.css')
|
||||
await writeFile(stylesheet, 'body { color: red; }\n')
|
||||
const context = recordingContext()
|
||||
|
||||
const output = await plugin.load.call({ addWatchFile: id => watched.push(id) }, virtualId)
|
||||
const output = await loadImport(bundlePlugin('dsh-css-text-inline'), context, './base.css?inline', join(root, 'index.ts'))
|
||||
|
||||
expect(watched).toEqual([stylesheet])
|
||||
expect(output).toContain('export default "body{color:red}"')
|
||||
expect(output).not.toContain('data-plugin-css')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
expect(context.watched).toEqual([stylesheet])
|
||||
expect(output).toContain('export default "body{color:red}"')
|
||||
expect(output).not.toContain('data-plugin-css')
|
||||
})
|
||||
})
|
||||
|
||||
describe('client bundle images', () => {
|
||||
it('embeds an imported image as a watched data URL and ignores other modules', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-client-image-watch-'))
|
||||
describe('client bundle package asset imports', () => {
|
||||
it('emits an imported asset once per build and exports its revisioned URL', async () => {
|
||||
const root = await assetPackage({ 'src/client/assets/icons/whale@2x.png': WHALE })
|
||||
const plugin = browserPlugins(root)('dsh-package-asset')
|
||||
const { resolveId, load } = hooks(plugin)
|
||||
const asset = join(root, 'src/client/assets/icons/whale@2x.png')
|
||||
const context = recordingContext()
|
||||
|
||||
const id = resolveId('./assets/icons/whale@2x.png', join(root, 'lib/types/client/index.js'))
|
||||
if (id === null) throw new Error('asset import did not resolve')
|
||||
const first = await load.call(context, id)
|
||||
const second = await load.call(context, id)
|
||||
|
||||
const expected = `export default ${resolverCall(first)}("plugins/@deepseek-ai/dsh-client-asset-fixture/assets/icons/whale%402x-${WHALE_REV}.png");`
|
||||
expect(id).toBe(asset)
|
||||
expect([first, second]).toEqual([expected, expected])
|
||||
expect(emittedFiles(context)).toEqual([{
|
||||
type: 'asset',
|
||||
fileName: `assets/icons/whale@2x-${WHALE_REV}.png`,
|
||||
source: Buffer.from(WHALE).toString('hex'),
|
||||
originalFileName: asset,
|
||||
}])
|
||||
expect(context.watched).toEqual([asset])
|
||||
|
||||
plugin.buildStart?.call(context)
|
||||
expect(await load.call(context, id)).toBe(expected)
|
||||
expect(context.emitted).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('renders each resolver call as the module loader require.asset in the CommonJS bundle only', async () => {
|
||||
const root = await assetPackage({ 'src/client/assets/whale.png': WHALE })
|
||||
const plugin = browserPlugins(root)('dsh-package-asset')
|
||||
const { renderChunk } = plugin
|
||||
if (renderChunk === undefined) throw new Error('asset plugin has no renderChunk hook')
|
||||
const call = resolverCall(await hooks(plugin).load.call(recordingContext(), join(root, 'src/client/assets/whale.png')))
|
||||
// Plugin data that only shares the fixed prefix stays as written.
|
||||
const literal = 'const text = "__dshPackageAsset(";\n'
|
||||
const code = `const a = ${call}("${WHALE_URL}");\nconst css = "x" + ${call}("${WHALE_URL}") + "y";\n${literal}`
|
||||
|
||||
expect(renderChunk(code, {}, { format: 'cjs' })?.toString())
|
||||
.toBe(`const a = require.asset("${WHALE_URL}");\nconst css = "x" + require.asset("${WHALE_URL}") + "y";\n${literal}`)
|
||||
expect(renderChunk(code, {}, { format: 'es' })).toBeNull()
|
||||
expect(renderChunk('const a = 1;\n', {}, { format: 'cjs' })).toBeNull()
|
||||
})
|
||||
|
||||
it('builds an asset import into require.asset and keeps plugin strings that share the prefix', async () => {
|
||||
const root = await assetPackage({
|
||||
'src/client/assets/whale.png': WHALE,
|
||||
'lib/types/client/index.js': 'import whale from "./assets/whale.png"\nexport const url = whale\nexport const text = "__dshPackageAsset("\n',
|
||||
})
|
||||
const config = clientFaceConfigs(root).find(candidate => candidate.platform === 'browser')
|
||||
if (config === undefined) throw new Error('browser config missing')
|
||||
|
||||
let builds: TsdownBundle[] = []
|
||||
try {
|
||||
const image = join(root, 'preview.svg')
|
||||
const importer = join(root, 'index.ts')
|
||||
await writeFile(image, '<svg/>')
|
||||
const plugin = bundlePlugin('dsh-image-inline')
|
||||
const id = plugin.resolveId?.('./preview.svg', importer)
|
||||
if (typeof id !== 'string' || plugin.load === undefined) throw new Error('image plugin hooks are incomplete')
|
||||
const watched: string[] = []
|
||||
|
||||
const output = await plugin.load.call({ addWatchFile: file => watched.push(file) }, id)
|
||||
|
||||
expect(watched).toEqual([image])
|
||||
expect(output).toBe(`export default "data:image/svg+xml;base64,${Buffer.from('<svg/>').toString('base64')}";`)
|
||||
expect(plugin.resolveId?.('./index.ts', importer)).toBeNull()
|
||||
expect(await plugin.load.call({ addWatchFile: file => watched.push(file) }, importer)).toBeNull()
|
||||
builds = await build({
|
||||
...config, cwd: root, config: false, tsconfig: false,
|
||||
write: false, clean: false, exports: false, report: false, logLevel: 'silent',
|
||||
})
|
||||
const output = builds.flatMap(bundle => bundle.chunks).find(chunk => chunk.type === 'chunk' && chunk.fileName === 'client.js')
|
||||
const code = output?.type === 'chunk' ? output.code : ''
|
||||
expect(code).toContain(`require.asset(${JSON.stringify(WHALE_URL)})`)
|
||||
expect(code).not.toContain('__dshPackageAsset_')
|
||||
expect(code).toContain('"__dshPackageAsset("')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
for (const bundle of builds) await bundle[Symbol.asyncDispose]()
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores imports and modules that are not supported assets', async () => {
|
||||
const root = await assetPackage({ 'src/client/index.ts': '', 'src/client/assets/font.woff2': 'font' })
|
||||
const { resolveId, load } = hooks(browserPlugins(root)('dsh-package-asset'))
|
||||
const importer = join(root, 'src/client/index.ts')
|
||||
const context = recordingContext()
|
||||
|
||||
expect(resolveId('./index.ts', importer)).toBeNull()
|
||||
expect(resolveId('./assets/font.woff2', importer)).toBeNull()
|
||||
expect(resolveId('./assets/whale.png', undefined)).toBeNull()
|
||||
expect(await load.call(context, importer)).toBeNull()
|
||||
expect(await load.call(context, 'whale.png')).toBeNull()
|
||||
expect(context.emitted).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects files outside the package src/client/assets directory', async () => {
|
||||
const other = await assetPackage({ 'src/client/assets/whale.png': WHALE })
|
||||
const root = await assetPackage({
|
||||
'src/client/whale.png': WHALE,
|
||||
'node_modules/dependency/package.json': JSON.stringify({ name: 'dependency' }),
|
||||
'node_modules/dependency/whale.png': WHALE,
|
||||
})
|
||||
const plugin = browserPlugins(root)('dsh-package-asset')
|
||||
const { load } = hooks(plugin)
|
||||
const importer = join(root, 'src/client/index.ts')
|
||||
const context = recordingContext()
|
||||
|
||||
await expect(loadImport(plugin, context, './whale.png', importer))
|
||||
.rejects.toThrow(`${join(root, 'src/client/whale.png')} is outside src/client/assets; move the asset there`)
|
||||
expect(() => load.call(context, join(other, 'src/client/assets/whale.png'))).toThrow('is outside src/client/assets')
|
||||
await expect(loadImport(plugin, context, 'dependency/whale.png', importer)).rejects.toThrow('is outside src/client/assets')
|
||||
expect(context.emitted).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['is missing', { './client': './lib/client.js' }],
|
||||
['targets another directory', { './assets/*': './src/client/assets/*' }],
|
||||
])('rejects an asset while the asset export %s', async (_case, exports) => {
|
||||
const root = await assetPackage({ 'src/client/assets/whale.png': WHALE }, exports)
|
||||
const { load } = hooks(browserPlugins(root)('dsh-package-asset'))
|
||||
|
||||
expect(() => load.call(recordingContext(), join(root, 'src/client/assets/whale.png')))
|
||||
.toThrow('needs package.json exports {"./assets/*":"./lib/assets/*"} and "lib/assets" in files')
|
||||
})
|
||||
|
||||
it('rejects a missing asset file by name', async () => {
|
||||
const root = await assetPackage({})
|
||||
const asset = join(root, 'src/client/assets/missing.png')
|
||||
const { load } = hooks(browserPlugins(root)('dsh-package-asset'))
|
||||
|
||||
expect(() => load.call(recordingContext(), asset)).toThrow(`${PACKAGE_NAME}: cannot resolve asset ${asset}`)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'src/client/assets/100%.png', 'src/client/assets/icons#1/whale.png', 'src/client/assets/Node_Modules/whale.png',
|
||||
])('rejects %s, which the asset route cannot serve', async (path) => {
|
||||
const root = await assetPackage({ [path]: WHALE })
|
||||
const { load } = hooks(browserPlugins(root)('dsh-package-asset'))
|
||||
|
||||
expect(() => load.call(recordingContext(), join(root, path)))
|
||||
.toThrow(`${join(root, path)} has a node_modules path segment or one containing %, #, ?, \\ or NUL, which the asset route cannot serve; rename it`)
|
||||
})
|
||||
|
||||
it('resolves every copy of a reference and tolerates a minifier dropping one', async () => {
|
||||
const root = await assetPackage({
|
||||
'src/client/assets/whale.png': WHALE,
|
||||
'src/client/assets/old.png': WHALE,
|
||||
'src/client/theme.css': '.a { background: url(./assets/old.png); background: url(./assets/whale.png); } .b { mask: url(./assets/whale.png); }',
|
||||
})
|
||||
|
||||
const output = await loadImport(browserPlugins(root)('dsh-css-global-inline'), recordingContext(), './theme.css', join(root, 'src/client/index.ts'))
|
||||
|
||||
const css = stylesheetText(output, reference => `resolved:${reference}`)
|
||||
expect(css.split(`url(resolved:${WHALE_URL})`)).toHaveLength(3)
|
||||
// lightningcss keeps only the last background, so the placeholder of old.png is missing from the text.
|
||||
expect(css).not.toContain('old-')
|
||||
expect(css).not.toContain('__dshPackageAsset')
|
||||
})
|
||||
|
||||
it('rewrites the urls inside image-set()', async () => {
|
||||
const root = await assetPackage({
|
||||
'src/client/assets/whale.png': WHALE,
|
||||
'src/client/theme.css': '.a { background-image: image-set(url(./assets/whale.png) 1x, url("data:image/png;base64,AA==") 2x); }',
|
||||
})
|
||||
const context = recordingContext()
|
||||
|
||||
const output = await loadImport(browserPlugins(root)('dsh-css-global-inline'), context, './theme.css', join(root, 'src/client/index.ts'))
|
||||
|
||||
const css = stylesheetText(output, reference => `resolved:${reference}`)
|
||||
expect(css).toContain(`resolved:${WHALE_URL}`)
|
||||
expect(css).toContain('data:image/png;base64,AA==')
|
||||
expect(context.emitted.map(file => file.fileName)).toEqual([`assets/whale-${WHALE_REV}.png`])
|
||||
})
|
||||
|
||||
it('keeps literal placeholder-like text and resolves only the references the rewrite inserted', async () => {
|
||||
const root = await assetPackage({
|
||||
'src/client/assets/whale.png': WHALE,
|
||||
'src/client/assets/__dshPackageAsset_logo.png': WHALE,
|
||||
'src/client/theme.css': [
|
||||
'.a::after { content: "__dshPackageAsset0__"; } .b { background: url(./assets/whale.png); }',
|
||||
'.c { background: url(./assets/__dshPackageAsset_logo.png); }',
|
||||
].join('\n'),
|
||||
})
|
||||
|
||||
const output = await loadImport(browserPlugins(root)('dsh-css-global-inline'), recordingContext(), './theme.css', join(root, 'src/client/index.ts'))
|
||||
|
||||
const css = stylesheetText(output, reference => `resolved:${reference}`)
|
||||
expect(css).toContain('content:"__dshPackageAsset0__"')
|
||||
expect(css).toContain(`url(resolved:${WHALE_URL})`)
|
||||
// A real file whose name shares the placeholder prefix is still a package asset.
|
||||
expect(css).toContain(`url(resolved:plugins/@deepseek-ai/dsh-client-asset-fixture/assets/__dshPackageAsset_logo-${WHALE_REV}.png)`)
|
||||
})
|
||||
|
||||
it('rejects an uppercase extension, which the asset import types do not declare', async () => {
|
||||
const root = await assetPackage({ 'src/client/assets/whale.PNG': WHALE })
|
||||
const { load } = hooks(browserPlugins(root)('dsh-package-asset'))
|
||||
const asset = join(root, 'src/client/assets/whale.PNG')
|
||||
|
||||
expect(() => load.call(recordingContext(), asset)).toThrow(`${asset} has an uppercase extension`)
|
||||
})
|
||||
|
||||
it('passes a file name lightningcss escapes through the resolver call, and rejects a fragment or query', async () => {
|
||||
const root = await assetPackage({
|
||||
'src/client/assets/whale(2x).png': WHALE,
|
||||
'src/client/View.module.css': '.a { mask: url(\'./assets/whale(2x).png\'); }',
|
||||
'src/client/Bad.module.css': '.b { mask: url(\'./assets/whale(2x).png#part\'); }',
|
||||
})
|
||||
const plugin = browserPlugins(root)('dsh-css-modules-inline')
|
||||
const importer = join(root, 'src/client/index.ts')
|
||||
|
||||
const output = await loadImport(plugin, recordingContext(), './View.module.css', importer)
|
||||
const reference = `plugins/@deepseek-ai/dsh-client-asset-fixture/assets/whale%282x%29-${WHALE_REV}.png`
|
||||
// The served Web app returns the reference unchanged; the text must still parse as one url() declaration.
|
||||
const css = stylesheetText(output, asset => asset)
|
||||
expect(css).toContain(`url(${reference})`)
|
||||
expect(transform({ filename: 'View.css', code: Buffer.from(css), minify: true }).code.toString()).toContain(`mask:url(${reference})`)
|
||||
await expect(loadImport(plugin, recordingContext(), './Bad.module.css', importer))
|
||||
.rejects.toThrow('a package asset url() takes no fragment or query')
|
||||
})
|
||||
|
||||
it('rejects asset imports in the Node Client face', async () => {
|
||||
const root = await assetPackage({})
|
||||
const node = clientFaceConfigs(root, true).find(config => config.name === `${PACKAGE_NAME}/client-node`)
|
||||
const { resolveId } = configPlugin(node, 'dsh-package-asset-browser-only')
|
||||
if (resolveId === undefined) throw new Error('browser-only guard has no resolveId hook')
|
||||
const importer = join(root, 'lib/types/client/index.js')
|
||||
|
||||
expect(() => resolveId('./assets/whale.png', importer)).toThrow(`${PACKAGE_NAME}: asset imports are browser-only (./assets/whale.png)`)
|
||||
expect(resolveId('./index.js', importer)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('client bundle stylesheet url()', () => {
|
||||
it('rewrites relative CSS Modules urls and keeps urls that name no package file', async () => {
|
||||
const root = await assetPackage({
|
||||
'src/client/assets/whale.png': WHALE,
|
||||
'src/client/View.module.css': [
|
||||
'.whale { mask: url(\'./assets/whale.png\') center / 100% 100% no-repeat; }',
|
||||
'@supports (mask-image: url(\'\')) { .empty { background: url(\'\'); } }',
|
||||
'.data { background: url("data:image/svg+xml,%3Csvg%3E"); }',
|
||||
'.fragment { mask: url(#mask); }',
|
||||
'.rooted { background: url(/static/a.png); }',
|
||||
'.remote { background: url(https://example.com/a.png); }',
|
||||
].join('\n'),
|
||||
})
|
||||
const context = recordingContext()
|
||||
|
||||
const output = await loadImport(browserPlugins(root)('dsh-css-modules-inline'), context, './View.module.css', join(root, 'src/client/index.ts'))
|
||||
|
||||
expect(output).toContain(`url(" + ${resolverCall(output)}(${JSON.stringify(WHALE_URL)}) + ")`)
|
||||
const css = stylesheetText(output, reference => `resolved:${reference}`)
|
||||
expect(css).toContain(`url(resolved:${WHALE_URL})`)
|
||||
for (const preserved of ['(mask-image:url(\'\'))', 'background:url()', 'url(data:image/svg+xml,%3Csvg%3E)', 'url(#mask)', 'url(/static/a.png)', 'url(https://example.com/a.png)']) {
|
||||
expect(css).toContain(preserved)
|
||||
}
|
||||
expect(emittedFiles(context)).toEqual([{
|
||||
type: 'asset',
|
||||
fileName: `assets/whale-${WHALE_REV}.png`,
|
||||
source: Buffer.from(WHALE).toString('hex'),
|
||||
originalFileName: join(root, 'src/client/assets/whale.png'),
|
||||
}])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['dsh-css-global-inline', './theme.css'],
|
||||
['dsh-css-text-inline', './theme.css?inline'],
|
||||
] as const)('rewrites relative urls through %s', async (name, source) => {
|
||||
const root = await assetPackage({
|
||||
'src/client/assets/whale.png': WHALE,
|
||||
'src/client/theme.css': 'body { background: url(./assets/whale.png); }',
|
||||
})
|
||||
const context = recordingContext()
|
||||
|
||||
const output = await loadImport(browserPlugins(root)(name), context, source, join(root, 'src/client/index.ts'))
|
||||
|
||||
expect(stylesheetText(output, reference => `resolved:${reference}`)).toContain(`url(resolved:${WHALE_URL})`)
|
||||
expect(context.emitted.map(file => file.fileName)).toEqual([`assets/whale-${WHALE_REV}.png`])
|
||||
})
|
||||
|
||||
it('leaves stylesheets outside the package src untouched', async () => {
|
||||
const root = await assetPackage({
|
||||
'node_modules/@fortune-sheet/react/dist/index.css': '.sheet { background: url("EwaAntH.gif"); }',
|
||||
})
|
||||
const context = recordingContext()
|
||||
|
||||
const output = await loadImport(
|
||||
browserPlugins(root)('dsh-css-global-inline'),
|
||||
context,
|
||||
'../../node_modules/@fortune-sheet/react/dist/index.css',
|
||||
join(root, 'src/client/index.ts'),
|
||||
)
|
||||
|
||||
expect(output).toContain('url(EwaAntH.gif)')
|
||||
expect(context.emitted).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['./assets/font.woff2', 'is not a supported asset; supported extensions: .svg, .png, .webp, .jpg, .jpeg'],
|
||||
['./whale.png', 'is outside src/client/assets; move the asset there'],
|
||||
['./assets/missing.png', 'cannot resolve asset'],
|
||||
])('rejects url(%s)', async (url, message) => {
|
||||
const root = await assetPackage({
|
||||
'src/client/whale.png': WHALE,
|
||||
'src/client/assets/font.woff2': 'font',
|
||||
'src/client/View.module.css': `.x { background: url(${url}); }`,
|
||||
})
|
||||
|
||||
await expect(loadImport(browserPlugins(root)('dsh-css-modules-inline'), recordingContext(), './View.module.css', join(root, 'src/client/index.ts')))
|
||||
.rejects.toThrow(message)
|
||||
})
|
||||
})
|
||||
|
||||
describe('client bundle asset output', () => {
|
||||
it('prunes lib/assets files this build did not emit and the directories they leave empty', async () => {
|
||||
const root = await assetPackage({
|
||||
'lib/client.js': '',
|
||||
'lib/assets/whale.png': WHALE,
|
||||
'lib/assets/icons/kept.svg': '<svg/>',
|
||||
'lib/assets/icons/stale.svg': '<svg/>',
|
||||
'lib/assets/old/deep/stale.webp': 'webp',
|
||||
})
|
||||
const { writeBundle } = browserPlugins(root)('dsh-package-asset')
|
||||
if (writeBundle === undefined) throw new Error('asset plugin has no writeBundle hook')
|
||||
const out = join(root, 'lib')
|
||||
const build = { meta: { watchMode: false } }
|
||||
|
||||
await writeBundle.call(build, { dir: out }, { 'client.js': {}, 'assets/whale.png': {}, 'assets/icons/kept.svg': {} })
|
||||
|
||||
expect(['client.js', 'assets/whale.png', 'assets/icons/kept.svg', 'assets/icons/stale.svg', 'assets/old']
|
||||
.map(path => [path, existsSync(join(out, path))]))
|
||||
.toEqual([
|
||||
['client.js', true],
|
||||
['assets/whale.png', true],
|
||||
['assets/icons/kept.svg', true],
|
||||
['assets/icons/stale.svg', false],
|
||||
['assets/old', false],
|
||||
])
|
||||
|
||||
await writeBundle.call(build, { dir: out }, { 'client.js': {} })
|
||||
expect([existsSync(join(out, 'client.js')), existsSync(join(out, 'assets'))]).toEqual([true, false])
|
||||
await writeBundle.call(build, { dir: out }, { 'client.js': {} })
|
||||
})
|
||||
|
||||
it('keeps earlier lib/assets files in a watch rebuild for pages running the previous generation', async () => {
|
||||
const root = await assetPackage({
|
||||
'lib/client.js': '',
|
||||
'lib/assets/whale-000000000000.png': WHALE,
|
||||
})
|
||||
const { writeBundle } = browserPlugins(root)('dsh-package-asset')
|
||||
if (writeBundle === undefined) throw new Error('asset plugin has no writeBundle hook')
|
||||
const out = join(root, 'lib')
|
||||
|
||||
await writeBundle.call({ meta: { watchMode: true } }, { dir: out }, { 'client.js': {}, 'assets/whale-111111111111.png': {} })
|
||||
|
||||
expect(existsSync(join(out, 'assets/whale-000000000000.png'))).toBe(true)
|
||||
})
|
||||
})
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package images under `src/client/assets/`: the client build preset replaces
|
||||
* each import with the asset's document-relative, content-revisioned URL.
|
||||
* The module names mirror the supported extensions of the client-modules
|
||||
* asset format.
|
||||
*/
|
||||
declare module '*.svg' {
|
||||
const url: string
|
||||
export default url
|
||||
}
|
||||
|
||||
declare module '*.png' {
|
||||
const url: string
|
||||
export default url
|
||||
}
|
||||
|
||||
declare module '*.webp' {
|
||||
const url: string
|
||||
export default url
|
||||
}
|
||||
|
||||
declare module '*.jpg' {
|
||||
const url: string
|
||||
export default url
|
||||
}
|
||||
|
||||
declare module '*.jpeg' {
|
||||
const url: string
|
||||
export default url
|
||||
}
|
||||
@@ -10,9 +10,12 @@ import {
|
||||
collectRuntimeLocalSourceSpecifiers,
|
||||
collectRuntimeSourcePackageUses,
|
||||
collectRuntimeSourceSpecifiers,
|
||||
collectSourceAssetSpecifiers,
|
||||
collectSourcePackageUses,
|
||||
collectStylesheetAssetUrls,
|
||||
fixClientPackageManifests,
|
||||
readClientDeclarations,
|
||||
readStylesheetAssetReferences,
|
||||
type ClientDeclaration,
|
||||
type ClientPackage,
|
||||
type ClientPackageFacts,
|
||||
@@ -33,10 +36,13 @@ function declaration(
|
||||
name: short.startsWith('@') ? short : '@deepseek-ai/dsh-client-' + short,
|
||||
manifest: 'packages/client/' + short.replace(/^.*\//, '') + '/package.json',
|
||||
dynamic: true,
|
||||
platform: 'web',
|
||||
external: [],
|
||||
inject: [],
|
||||
runtimeSourceUses: {},
|
||||
runtimeSourceSpecifiers: {},
|
||||
assetExport: undefined,
|
||||
assetReferences: [],
|
||||
...fields,
|
||||
}
|
||||
}
|
||||
@@ -285,6 +291,164 @@ describe('module requests', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('package assets', () => {
|
||||
const art = { file: 'packages/client/ui/src/client/View.tsx', reference: '"./assets/art.webp"' }
|
||||
const mask = { file: 'packages/client/ui/src/client/View.module.css', reference: 'url("../assets/mask.png")' }
|
||||
|
||||
it('collects relative runtime module specifiers with a package-asset extension', () => {
|
||||
expect([...collectSourceAssetSpecifiers('View.tsx', [
|
||||
"import art from './assets/art.webp'",
|
||||
"import back from '../assets/icons/back.SVG'",
|
||||
"import './assets/side-effect.png'",
|
||||
"export { default as photo } from './assets/photo.jpeg'",
|
||||
"const lazy = () => import('./assets/lazy.jpg')",
|
||||
"const legacy = require('./assets/legacy.svg')",
|
||||
"import type Art from './assets/type-only.webp'",
|
||||
"import styles from './View.module.css'",
|
||||
"import font from './assets/font.woff2'",
|
||||
"import raw from './assets/raw.svg?raw'",
|
||||
"import shared from '@deepseek-ai/dsh-client-other/assets/shared.svg'",
|
||||
"import rooted from '/assets/rooted.svg'",
|
||||
].join('\n'))]).toEqual([
|
||||
'./assets/art.webp',
|
||||
'../assets/icons/back.SVG',
|
||||
'./assets/side-effect.png',
|
||||
'./assets/photo.jpeg',
|
||||
'./assets/lazy.jpg',
|
||||
'./assets/legacy.svg',
|
||||
])
|
||||
})
|
||||
|
||||
it.each(['View.module.css', 'global.css'])('collects relative url() values from %s', (path) => {
|
||||
expect(collectStylesheetAssetUrls(path, [
|
||||
".plain { background: url('./x.png'); }",
|
||||
'.parent { mask: url("../assets/x.png") center / 100% no-repeat; }',
|
||||
'.bare { background: url(./bare.svg); }',
|
||||
".empty { background: url(''); }",
|
||||
'.inline { background: url("data:image/svg+xml,%3Csvg%3E%3C/svg%3E"); }',
|
||||
".fragment { filter: url('#frag'); }",
|
||||
".rooted { background: url('/abs/x.png'); }",
|
||||
'.network { background: url(//cdn.example.com/x.png); }',
|
||||
".remote { background: url('https://example.com/x.png'); }",
|
||||
"@supports (mask-image: url('')) { .nested { mask: url(./nested.svg); } }",
|
||||
".set { background-image: image-set(url('./set@1x.png') 1x, url('data:image/png;base64,AA==') 2x); }",
|
||||
].join('\n'))).toEqual(['./x.png', '../assets/x.png', './bare.svg', './nested.svg', './set@1x.png'])
|
||||
})
|
||||
|
||||
it('rejects an unparsable stylesheet with its path', () => {
|
||||
expect(() => collectStylesheetAssetUrls('src/View.module.css', '.a { composes: b; } .c .d { composes: a; }'))
|
||||
.toThrow('verify-client-packages: cannot parse stylesheet src/View.module.css: The `composes` property')
|
||||
})
|
||||
|
||||
it('reads url() references only from package stylesheets that Client sources import', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'client-package-assets-'))
|
||||
roots.push(root)
|
||||
const files: Record<string, string> = {
|
||||
'packages/client/ui/src/client/View.module.css': ".view { mask: url('../assets/mask.png'); background: url(data:,x); }",
|
||||
'packages/client/ui/src/client/global.css': '.global { background: url(./assets/global.svg); }',
|
||||
'packages/client/ui/src/client/text.css': '.text { background: url("./assets/text.webp"); }',
|
||||
'packages/client/ui/src/styles/static.css': '@font-face { src: url(./static.woff2); }',
|
||||
'packages/client/ui/styles/outside.css': '.outside { background: url(./outside.png); }',
|
||||
}
|
||||
for (const [path, value] of Object.entries(files)) {
|
||||
mkdirSync(dirname(join(root, path)), { recursive: true })
|
||||
writeFileSync(join(root, path), value)
|
||||
}
|
||||
|
||||
expect(readStylesheetAssetReferences(root, 'packages/client/ui', new Map([
|
||||
['packages/client/ui/src/client/View.tsx', ['./View.module.css', './global.css', './assets/art.webp']],
|
||||
['packages/client/ui/src/client/lazy.ts', ['./text.css?inline', '../../styles/outside.css', './View.module.css']],
|
||||
['packages/client/ui/src/index.ts', ['katex/dist/katex.css', './client/missing.css.ts']],
|
||||
]))).toEqual({
|
||||
stylesheets: [
|
||||
'packages/client/ui/src/client/View.module.css',
|
||||
'packages/client/ui/src/client/global.css',
|
||||
'packages/client/ui/src/client/text.css',
|
||||
],
|
||||
references: [
|
||||
{ file: 'packages/client/ui/src/client/View.module.css', reference: 'url("../assets/mask.png")' },
|
||||
{ file: 'packages/client/ui/src/client/global.css', reference: 'url("./assets/global.svg")' },
|
||||
{ file: 'packages/client/ui/src/client/text.css', reference: 'url("./assets/text.webp")' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('reads the Web platform and the raw asset export from manifests', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'client-package-asset-manifests-'))
|
||||
roots.push(root)
|
||||
const files: Record<string, unknown> = {
|
||||
'packages/g/web/package.json': {
|
||||
name: '@f/web', dsh: { client: { platform: 'web' } }, exports: { './assets/*': './lib/assets/*' },
|
||||
},
|
||||
'packages/g/conditional/package.json': {
|
||||
name: '@f/conditional', exports: { './assets/*': { default: './lib/assets/*' } },
|
||||
},
|
||||
'packages/g/none/package.json': { name: '@f/none', dsh: { client: { platform: 1 } }, exports: './lib/index.js' },
|
||||
}
|
||||
for (const [path, value] of Object.entries(files)) {
|
||||
mkdirSync(dirname(join(root, path)), { recursive: true })
|
||||
writeFileSync(join(root, path), JSON.stringify(value))
|
||||
}
|
||||
|
||||
expect(readClientDeclarations(root).declarations.map(entry => [entry.name, entry.platform, entry.assetExport]))
|
||||
.toEqual([
|
||||
['@f/conditional', undefined, { default: './lib/assets/*' }],
|
||||
['@f/none', undefined, undefined],
|
||||
['@f/web', 'web', './lib/assets/*'],
|
||||
])
|
||||
})
|
||||
|
||||
it('accepts referenced assets with the export and unreferenced packages without it', () => {
|
||||
expect(collectClientPackageViolations(facts([], {
|
||||
declarations: [
|
||||
declaration('ui', { assetExport: './lib/assets/*', assetReferences: [mask, art] }),
|
||||
declaration('plain'),
|
||||
declaration('shell', { dynamic: false, platform: undefined }),
|
||||
],
|
||||
}))).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['./lib/assets/**', '"./lib/assets/**"'],
|
||||
[{ default: './lib/assets/*' }, '{"default":"./lib/assets/*"}'],
|
||||
[null, 'null'],
|
||||
])('rejects the asset export target %j', (target, shown) => {
|
||||
const ui = declaration('ui', { assetExport: target, assetReferences: [art] })
|
||||
expect(collectClientPackageViolations(facts([], { declarations: [ui] }))).toEqual([
|
||||
ui.manifest + ': exports["./assets/*"] is ' + shown + '; it must be exactly "./lib/assets/*"',
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a statically linked package', { dynamic: false, platform: undefined }],
|
||||
['a row for another platform', { platform: 'desktop' }],
|
||||
['a row without a platform', { platform: undefined }],
|
||||
] as const)('rejects the asset export on %s', (_label, fields) => {
|
||||
const ui = declaration('ui', { ...fields, assetExport: './lib/assets/*', assetReferences: [art] })
|
||||
expect(collectClientPackageViolations(facts([], { declarations: [ui] }))).toEqual([
|
||||
ui.manifest + ': ' + ui.name + ' declares exports["./assets/*"] without a dsh.client row for platform "web";'
|
||||
+ ' only dynamic Web Client packages publish package assets',
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects a referenced asset without the export and names the first reference', () => {
|
||||
const ui = declaration('ui', { assetReferences: [mask, art] })
|
||||
expect(collectClientPackageViolations(facts([], { declarations: [ui] }))).toEqual([
|
||||
ui.manifest + ': ' + ui.name + ' references package asset url("../assets/mask.png") in '
|
||||
+ 'packages/client/ui/src/client/View.module.css but declares no exports["./assets/*"];'
|
||||
+ ' add "./assets/*": "./lib/assets/*" and publish "lib/assets"',
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects an unused asset export', () => {
|
||||
const ui = declaration('ui', { assetExport: './lib/assets/*' })
|
||||
expect(collectClientPackageViolations(facts([], { declarations: [ui] }))).toEqual([
|
||||
ui.manifest + ': ' + ui.name + ' declares an unused ./assets/* export;'
|
||||
+ ' no Client source or stylesheet it imports references a package asset',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('manifest declarations', () => {
|
||||
it('reports malformed arrays without hiding other packages', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'client-packages-'))
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
/**
|
||||
* Verify client package modes and the synchronous browser module-request graph.
|
||||
* Verify client package modes, the synchronous browser module-request graph, and package-asset publication.
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, resolve, sep } from 'node:path'
|
||||
import { dirname, posix, resolve, sep } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { transform } from 'lightningcss'
|
||||
import ts from 'typescript'
|
||||
import {
|
||||
PACKAGE_ASSET_EXPORT,
|
||||
PACKAGE_ASSET_EXPORT_TARGET,
|
||||
PACKAGE_ASSET_FILES_ENTRY,
|
||||
packageAssetMediaType,
|
||||
} from '../packages/client/modules/src/assets.ts'
|
||||
import { TypeScriptProject } from './ts-project.ts'
|
||||
|
||||
const GATE = 'verify-client-packages'
|
||||
@@ -16,18 +23,38 @@ const PLATFORM_SOURCE = 'packages/client/web/src/platform.ts'
|
||||
const PARSER_PRELOAD_SOURCE = 'packages/client/modules/src/index.ts'
|
||||
const STATIC_PRESET_SOURCE = 'packages/client/tsdown.client.ts'
|
||||
const CORDIS = '@deepseek-ai/cordis'
|
||||
/** Stylesheet requests the dynamic build preset compiles: CSS Modules, global sheets, and `?inline` text. */
|
||||
const STYLESHEET_REQUEST = /\.css(?:\?inline)?$/u
|
||||
const INLINE_STYLESHEET_QUERY = /\?inline$/u
|
||||
|
||||
/** One source location whose reference makes the dynamic build preset emit a package asset. */
|
||||
export interface AssetReference {
|
||||
/** Repository-relative, `/`-separated source or stylesheet path. */
|
||||
readonly file: string
|
||||
/** The JSON-quoted module specifier, or `url("<value>")` for a stylesheet reference. */
|
||||
readonly reference: string
|
||||
}
|
||||
|
||||
/** One workspace package's browser-module declaration. */
|
||||
export interface ClientDeclaration {
|
||||
readonly name: string
|
||||
readonly manifest: string
|
||||
readonly dynamic: boolean
|
||||
/** String `dsh.client.platform`; undefined without a row or a string platform. */
|
||||
readonly platform: string | undefined
|
||||
readonly external: readonly string[]
|
||||
readonly runtimeSourceUses: Readonly<Record<string, readonly string[]>>
|
||||
/** Exact runtime specifiers used to validate `dsh.client.external` declarations. */
|
||||
readonly runtimeSourceSpecifiers: Readonly<Record<string, readonly string[]>>
|
||||
/** Informational package dependencies declared by the row. */
|
||||
readonly inject: readonly string[]
|
||||
/** Raw `exports["./assets/*"]` value; undefined when the manifest declares no such subpath. */
|
||||
readonly assetExport: unknown
|
||||
/**
|
||||
* Package-asset references in a dynamic Web row's Client sources under `src/` and in the package stylesheets they
|
||||
* import, sorted by file and then reference; empty for every other package.
|
||||
*/
|
||||
readonly assetReferences: readonly AssetReference[]
|
||||
}
|
||||
|
||||
/** One package directly under packages/client. */
|
||||
@@ -112,6 +139,94 @@ export function collectRuntimeLocalSourceSpecifiers(path: string, source: string
|
||||
return collectSourceFileUses(sourceFile, true, 'local', includeRootRelative)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect package-asset module specifiers retained by one production source file.
|
||||
* @param path - File path used to select TypeScript's parser mode.
|
||||
* @param source - Source text to inspect.
|
||||
* @returns Relative runtime imports, re-exports, and requires whose extension has a package-asset media type.
|
||||
*/
|
||||
export function collectSourceAssetSpecifiers(path: string, source: string): Set<string> {
|
||||
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
|
||||
return new Set([...collectSourceFileUses(sourceFile, true, 'local')].filter(isAssetSpecifier))
|
||||
}
|
||||
|
||||
function isAssetSpecifier(specifier: string): boolean {
|
||||
return packageAssetMediaType(specifier) !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the relative `url()` values of one stylesheet, which the dynamic build preset resolves to package assets.
|
||||
* @param path - Stylesheet path; a `.module.css` name enables CSS Modules syntax, as in the build preset.
|
||||
* @param source - Stylesheet text.
|
||||
* @returns `url()` values in source order, excluding empty values, fragments, root-relative paths, and URLs with a
|
||||
* scheme such as `data:` or `https:`.
|
||||
* @throws when lightningcss cannot parse the stylesheet; the message names `path`.
|
||||
*/
|
||||
export function collectStylesheetAssetUrls(path: string, source: string): string[] {
|
||||
const urls: string[] = []
|
||||
try {
|
||||
transform({
|
||||
filename: path,
|
||||
code: Buffer.from(source),
|
||||
cssModules: path.endsWith('.module.css'),
|
||||
visitor: {
|
||||
Url(url) {
|
||||
if (isRelativeStylesheetUrl(url.url)) urls.push(url.url)
|
||||
},
|
||||
// lightningcss does not visit the urls inside image-set(), so its options are read here.
|
||||
Image(image) {
|
||||
if (image.type !== 'image-set') return
|
||||
for (const option of image.value.options) {
|
||||
if (option.image.type === 'url' && isRelativeStylesheetUrl(option.image.value.url)) urls.push(option.image.value.url)
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
GATE + ': cannot parse stylesheet ' + path + ': ' + (error instanceof Error ? error.message : String(error)),
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
return urls
|
||||
}
|
||||
|
||||
function isRelativeStylesheetUrl(url: string): boolean {
|
||||
return url !== '' && !url.startsWith('#') && !url.startsWith('/') && !/^[a-z][a-z\d+.-]*:/iu.test(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read package-asset references from the package stylesheets that Client sources import. The dynamic build preset
|
||||
* compiles exactly these stylesheets; a stylesheet no Client source imports, such as a copied static payload, keeps
|
||||
* its `url()` values unchanged.
|
||||
* @param root - Absolute repository root.
|
||||
* @param packageDirectory - Repository-relative, `/`-separated package directory.
|
||||
* @param importers - Repository-relative, `/`-separated Client source paths and their runtime local module specifiers.
|
||||
* @returns Imported `.css` and `.css?inline` stylesheets under the package's `src/`, in path order, and their references.
|
||||
*/
|
||||
export function readStylesheetAssetReferences(
|
||||
root: string,
|
||||
packageDirectory: string,
|
||||
importers: ReadonlyMap<string, Iterable<string>>,
|
||||
): { readonly stylesheets: readonly string[]; readonly references: readonly AssetReference[] } {
|
||||
const sourcePrefix = packageDirectory + '/src/'
|
||||
const imported = new Set<string>()
|
||||
for (const [file, specifiers] of importers) {
|
||||
for (const specifier of specifiers) {
|
||||
if (!specifier.startsWith('.') || !STYLESHEET_REQUEST.test(specifier)) continue
|
||||
const stylesheet = posix.join(posix.dirname(file), specifier.replace(INLINE_STYLESHEET_QUERY, ''))
|
||||
if (stylesheet.startsWith(sourcePrefix)) imported.add(stylesheet)
|
||||
}
|
||||
}
|
||||
const stylesheets = [...imported].sort()
|
||||
return {
|
||||
stylesheets,
|
||||
references: stylesheets.flatMap(file =>
|
||||
collectStylesheetAssetUrls(file, readFileSync(resolve(root, file), 'utf8'))
|
||||
.map(url => ({ file, reference: 'url(' + JSON.stringify(url) + ')' }))),
|
||||
}
|
||||
}
|
||||
|
||||
function importCarriesRuntimeValue(node: ts.ImportDeclaration): boolean {
|
||||
const clause = node.importClause
|
||||
if (clause === undefined) return true
|
||||
@@ -190,7 +305,7 @@ export function readClientDeclarations(root: string): ClientDeclarations {
|
||||
|
||||
/**
|
||||
* Return every client package policy violation.
|
||||
* @param facts - Package modes, manifests, source uses, and platform module lists.
|
||||
* @param facts - Package modes, manifests, source uses, package-asset references, and platform module lists.
|
||||
* @returns Stable self-contained diagnostics.
|
||||
*/
|
||||
export function collectClientPackageViolations(facts: ClientPackageFacts): string[] {
|
||||
@@ -198,6 +313,7 @@ export function collectClientPackageViolations(facts: ClientPackageFacts): strin
|
||||
...facts.malformed,
|
||||
...collectModeViolations(facts),
|
||||
...collectModuleViolations(facts),
|
||||
...collectAssetViolations(facts),
|
||||
].sort((left, right) => left.localeCompare(right))
|
||||
}
|
||||
|
||||
@@ -440,9 +556,51 @@ function formatCycle(
|
||||
return manifest + ': synchronous dsh.client.external cycle: ' + chain + ' ' + (entry?.from ?? '')
|
||||
}
|
||||
|
||||
/** Whether the dynamic build preset builds this package's browser face, the only face that emits package assets. */
|
||||
function buildsDynamicWebClient(declaration: ClientDeclaration): boolean {
|
||||
return declaration.dynamic && declaration.platform === 'web'
|
||||
}
|
||||
|
||||
function collectAssetViolations(facts: ClientPackageFacts): string[] {
|
||||
const violations: string[] = []
|
||||
const exportKey = 'exports[' + JSON.stringify(PACKAGE_ASSET_EXPORT) + ']'
|
||||
for (const pkg of facts.declarations) {
|
||||
const declared = pkg.assetExport !== undefined
|
||||
const covered = buildsDynamicWebClient(pkg)
|
||||
if (declared && pkg.assetExport !== PACKAGE_ASSET_EXPORT_TARGET) {
|
||||
violations.push(
|
||||
pkg.manifest + ': ' + exportKey + ' is ' + JSON.stringify(pkg.assetExport)
|
||||
+ '; it must be exactly ' + JSON.stringify(PACKAGE_ASSET_EXPORT_TARGET),
|
||||
)
|
||||
}
|
||||
if (declared && !covered) {
|
||||
violations.push(
|
||||
pkg.manifest + ': ' + pkg.name + ' declares ' + exportKey + ' without a dsh.client row for platform "web";'
|
||||
+ ' only dynamic Web Client packages publish package assets',
|
||||
)
|
||||
}
|
||||
if (!covered) continue
|
||||
const first = pkg.assetReferences[0]
|
||||
if (!declared && first !== undefined) {
|
||||
violations.push(
|
||||
pkg.manifest + ': ' + pkg.name + ' references package asset ' + first.reference + ' in ' + first.file
|
||||
+ ' but declares no ' + exportKey + '; add ' + JSON.stringify(PACKAGE_ASSET_EXPORT) + ': '
|
||||
+ JSON.stringify(PACKAGE_ASSET_EXPORT_TARGET) + ' and publish ' + JSON.stringify(PACKAGE_ASSET_FILES_ENTRY),
|
||||
)
|
||||
} else if (declared && first === undefined) {
|
||||
violations.push(
|
||||
pkg.manifest + ': ' + pkg.name + ' declares an unused ' + PACKAGE_ASSET_EXPORT + ' export;'
|
||||
+ ' no Client source or stylesheet it imports references a package asset',
|
||||
)
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
interface Manifest {
|
||||
name?: unknown
|
||||
dsh?: unknown
|
||||
exports?: unknown
|
||||
dependencies?: Record<string, string>
|
||||
peerDependencies?: Record<string, string>
|
||||
devDependencies?: Record<string, string>
|
||||
@@ -457,27 +615,27 @@ function readDeclaration(
|
||||
if (typeof manifest.name !== 'string') return undefined
|
||||
const dsh = isRecord(manifest.dsh) ? manifest.dsh : undefined
|
||||
const rawClient = dsh?.client
|
||||
if (rawClient === undefined) {
|
||||
return {
|
||||
name: manifest.name, manifest: manifestPath, dynamic: false, external: [], inject: [],
|
||||
runtimeSourceUses: {}, runtimeSourceSpecifiers: {},
|
||||
}
|
||||
}
|
||||
const assetExport = isRecord(manifest.exports) ? manifest.exports[PACKAGE_ASSET_EXPORT] : undefined
|
||||
const rowless = {
|
||||
name: manifest.name, manifest: manifestPath, dynamic: false, platform: undefined, external: [], inject: [],
|
||||
runtimeSourceUses: {}, runtimeSourceSpecifiers: {}, assetExport, assetReferences: [],
|
||||
} satisfies ClientDeclaration
|
||||
if (rawClient === undefined) return rowless
|
||||
if (!isRecord(rawClient)) {
|
||||
malformed.push(manifestPath + ': ' + manifest.name + ' dsh.client must be an object')
|
||||
return {
|
||||
name: manifest.name, manifest: manifestPath, dynamic: false, external: [], inject: [],
|
||||
runtimeSourceUses: {}, runtimeSourceSpecifiers: {},
|
||||
}
|
||||
return rowless
|
||||
}
|
||||
return {
|
||||
name: manifest.name,
|
||||
manifest: manifestPath,
|
||||
dynamic: true,
|
||||
platform: typeof rawClient.platform === 'string' ? rawClient.platform : undefined,
|
||||
external: stringArray(rawClient.external, manifest.name, manifestPath, 'external', malformed),
|
||||
inject: stringArray(rawClient.inject, manifest.name, manifestPath, 'inject', malformed),
|
||||
runtimeSourceUses: {},
|
||||
runtimeSourceSpecifiers: {},
|
||||
assetExport,
|
||||
assetReferences: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -563,9 +721,13 @@ async function readFacts(root: string): Promise<ClientPackageFacts> {
|
||||
const staticLinkedPackages = await readStaticLinkedRoster(root)
|
||||
const project = new TypeScriptProject(root, 'client')
|
||||
const sourceFiles = project.sourceFiles()
|
||||
let webStylesheets = 0
|
||||
const declarations = bareDeclarations.map((declaration): ClientDeclaration => {
|
||||
const runtimeSourceUses = new Map<string, Set<string>>()
|
||||
const runtimeSourceSpecifiers = new Map<string, Set<string>>()
|
||||
const assetReferences: AssetReference[] = []
|
||||
const scansAssets = buildsDynamicWebClient(declaration)
|
||||
const importers = new Map<string, ReadonlySet<string>>()
|
||||
const sourcePrefix = dirname(declaration.manifest) + '/src/'
|
||||
for (const sourceFile of sourceFiles) {
|
||||
if (sourceFile.isDeclarationFile) continue
|
||||
@@ -581,6 +743,24 @@ async function readFacts(root: string): Promise<ClientPackageFacts> {
|
||||
locations.add(file)
|
||||
runtimeSourceSpecifiers.set(specifier, locations)
|
||||
}
|
||||
if (!scansAssets) continue
|
||||
const localSpecifiers = collectSourceFileUses(sourceFile, true, 'local')
|
||||
importers.set(file, localSpecifiers)
|
||||
for (const specifier of localSpecifiers) {
|
||||
if (isAssetSpecifier(specifier)) assetReferences.push({ file, reference: JSON.stringify(specifier) })
|
||||
}
|
||||
}
|
||||
if (scansAssets) {
|
||||
// A Web row always has a browser half, so an empty source set means the Client program stopped covering it.
|
||||
if (importers.size === 0) {
|
||||
throw new Error(
|
||||
GATE + ': the Client program loads no source under ' + sourcePrefix + ' for dynamic Web row '
|
||||
+ declaration.name + '; its package-asset references cannot be read',
|
||||
)
|
||||
}
|
||||
const stylesheets = readStylesheetAssetReferences(root, dirname(declaration.manifest), importers)
|
||||
webStylesheets += stylesheets.stylesheets.length
|
||||
assetReferences.push(...stylesheets.references)
|
||||
}
|
||||
return {
|
||||
...declaration,
|
||||
@@ -592,8 +772,13 @@ async function readFacts(root: string): Promise<ClientPackageFacts> {
|
||||
[...runtimeSourceSpecifiers].sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([specifier, locations]) => [specifier, [...locations].sort()]),
|
||||
),
|
||||
assetReferences: assetReferences.sort((left, right) =>
|
||||
left.file.localeCompare(right.file) || left.reference.localeCompare(right.reference)),
|
||||
}
|
||||
})
|
||||
if (webStylesheets === 0) {
|
||||
throw new Error(GATE + ': no dynamic Web row imports a stylesheet from its src/ directory; the stylesheet scan read an empty corpus')
|
||||
}
|
||||
const byManifest = new Map(declarations.map(entry => [entry.manifest, entry]))
|
||||
const packages: ClientPackage[] = []
|
||||
|
||||
@@ -701,10 +886,12 @@ async function main(): Promise<void> {
|
||||
|
||||
const dynamic = facts.packages.filter(pkg => pkg.dynamic).length
|
||||
const requests = facts.declarations.reduce((total, pkg) => total + pkg.external.length, 0)
|
||||
const assetExports = facts.declarations.filter(pkg => pkg.assetExport !== undefined).length
|
||||
console.log(
|
||||
GATE + ': ' + String(facts.packages.length) + ' client packages (' + String(dynamic) + ' dynamic, '
|
||||
+ String(facts.packages.length - dynamic) + ' statically linked) satisfy package-mode and module-request rules; '
|
||||
+ String(requests) + ' explicit external request(s).',
|
||||
+ String(facts.packages.length - dynamic) + ' statically linked) satisfy package-mode, module-request, and '
|
||||
+ 'package-asset rules; ' + String(requests) + ' explicit external request(s), ' + String(assetExports)
|
||||
+ ' package-asset export(s).',
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -707,6 +707,36 @@ describe('face-aware source classification', () => {
|
||||
})).rejects.toThrow('Host export ./node cannot map ./other/node.js to a source entry')
|
||||
})
|
||||
|
||||
it.each(['./lib/assets/*', { default: './lib/assets/*.js' }])(
|
||||
'does not classify the package-asset export %j as Host code', async (target) => {
|
||||
const root = writeWorkspace({
|
||||
'packages/client/ui-probe/package.json': {
|
||||
name: '@deepseek-ai/dsh-client-ui-probe',
|
||||
dsh: { client: { platform: 'web' } },
|
||||
exports: {
|
||||
'.': { types: './lib/types/index.d.ts', default: './lib/index.js' },
|
||||
'./client': { types: './lib/types/client/index.d.ts', default: './lib/client.js' },
|
||||
'./assets/*': target,
|
||||
},
|
||||
...CORDIS_PEER,
|
||||
},
|
||||
'packages/client/ui-probe/src/index.ts': 'export function apply() {}\n',
|
||||
'packages/client/ui-probe/src/client/index.ts': "import art from './assets/art.webp'\nexport const image = art\n",
|
||||
})
|
||||
|
||||
const state = await readPackageDependencyState(root, policy())
|
||||
|
||||
expect(state.facts.map(fact => fact.manifestPath)).toEqual(['packages/client/ui-probe/package.json'])
|
||||
expect(collectPackageDependencyViolations(state)).toEqual([])
|
||||
},
|
||||
)
|
||||
|
||||
it('scans other subpaths below ./assets as Host exports', async () => {
|
||||
await expect(sourceFacts({ 'src/index.ts': 'export function apply() {}' }, {
|
||||
exports: { './assets/index': './lib/assets/index.js' },
|
||||
})).rejects.toThrow('Host export ./assets/index has no source entry')
|
||||
})
|
||||
|
||||
it('fails when a managed Host package has no Host entry', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-package-missing-host-'))
|
||||
roots.push(root)
|
||||
|
||||
@@ -10,6 +10,7 @@ import { dirname, extname, join, normalize, relative, resolve, sep } from 'node:
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import ts from 'typescript'
|
||||
import { WorkspaceTypertGenerator } from '../packages/typert/generator/src/workspace.ts'
|
||||
import { PACKAGE_ASSET_EXPORT } from '../packages/client/modules/src/assets.ts'
|
||||
import { writeModuleGraph } from './gen-module-graph.ts'
|
||||
import {
|
||||
hasClientDeclaration,
|
||||
@@ -363,7 +364,10 @@ function generatedHostSources(root: string, packages: readonly WorkspacePackageM
|
||||
return sources
|
||||
}
|
||||
|
||||
/** Source-backed Node exports; generated Typert artifacts have no standalone source entry. */
|
||||
/**
|
||||
* Source-backed Node exports. Generated Typert artifacts have no standalone source entry; `./client`, `./src`, and
|
||||
* package-asset subpaths publish no Host code.
|
||||
*/
|
||||
function hostSourceEntries(root: string, pkg: WorkspacePackageManifest): string[] {
|
||||
const entry = resolve(root, pkg.dir, 'src/index.ts')
|
||||
if (!existsSync(entry)) {
|
||||
@@ -373,7 +377,8 @@ function hostSourceEntries(root: string, pkg: WorkspacePackageManifest): string[
|
||||
const exports = pkg.manifest.exports
|
||||
if (exports === null || typeof exports !== 'object') return [...entries]
|
||||
for (const [subpath, declaration] of Object.entries(exports as Record<string, unknown>)) {
|
||||
if (subpath === '.' || subpath === './package.json' || /^\.\/(?:client|src)(?:\/|$)/.test(subpath)) continue
|
||||
if (subpath === '.' || subpath === './package.json' || subpath === PACKAGE_ASSET_EXPORT
|
||||
|| /^\.\/(?:client|src)(?:\/|$)/.test(subpath)) continue
|
||||
const types = declaration !== null && typeof declaration === 'object' && 'types' in declaration
|
||||
? declaration.types
|
||||
: undefined
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
// Client-side compiler shape shared by tsconfig.client.json and every
|
||||
// packages/client/* package: browser library API, React JSX, no ambient
|
||||
// packages/client/* package: browser library API, React JSX, build-time
|
||||
// environment values and package-image module declarations, no ambient
|
||||
// node types (packages that need them override locally).
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["ES2024", "DOM", "DOM.Iterable", "ESNext.Disposable"],
|
||||
"typeRoots": ["./scripts/types", "./node_modules/@types"],
|
||||
"types": ["client-build-environment"]
|
||||
"types": ["client-build-environment", "client-assets"]
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user