fix(client): address package asset review findings

This commit is contained in:
imccyu committed 2026-10-20 02:43:09 +08:00
1 parent c1d9f141b9
commit 299741d819
19 files changed
+122 -58

No files matched your search

@@ -15,14 +15,14 @@
en: 69ca2e4ba27b2841
zh: cb7d7c1bec1adf7b
/agent-note-client-plugin-package-assets-served-by-url/decision/build:
en: 1751930093d55cea
zh: 8e92e2de062edb96
en: da95b904bd60815f
zh: 3a598bacc159abc4
/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: 54acdc86819cee80
zh: 28abe4517cf14182
en: 019003f4e09ffc60
zh: ecabc5118b107079
/agent-note-client-plugin-package-assets-served-by-url/decision/changes-and-package-manifests:
en: eedff52d04763c50
zh: 203bfcf81242b8fe
@@ -30,7 +30,7 @@ The supported extensions are `.svg`, `.png`, `.webp`, `.jpg`, and `.jpeg`. [`src
### 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, or when a path segment contains `%`, `#`, `?`, `\`, or NUL, which the Host route cannot serve. 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, and dependency stylesheets are not rewritten. 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.
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. 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
@@ -40,7 +40,7 @@ A served asset streams from disk on every request; the Host keeps neither its by
### 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 reference. The page turns each 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 the archive crosses only the thread boundary.
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 reference. The page turns each 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
@@ -30,7 +30,7 @@ plugins/@deepseek-ai/dsh-client-ui-chat/assets/running-whale%402x-06738fa29b2f.p
### 构建
动态浏览器 face 的 `dsh-package-asset` 插件按源码位置解析 import。以下情况构建失败:文件真实路径不在本包 `src/client/assets/` 内(他包或依赖里的图片也因此被拒绝);`exports["./assets/*"]` 不是恰好 `./lib/assets/*`;扩展名不受支持;路径段含 Host 路由无法提供的 `%`、`#`、`?`、`\` 或 NUL。每个文件每次构建只产出一次,并登记为监听输入。三条 CSS 管线(CSS Modules、全局样式、`?inline`)对本包 `src/` 内的样式表,经 lightningcss visitor 改写相对 `url()`;`data:`、片段、带协议、以 `/` 开头和空的 URL 保持不变,依赖包的样式表不改写。双 face 包的 Node Client face 拒绝资源 import。浏览器 face 是 `lib/assets/` 的唯一写入方:一次性构建会删除本次没有产出的文件,watch 重建则保留它们,因为 Host 仍在提供上一代 bundle,运行它的页面会请求那些文件名。
动态浏览器 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 从不转义的标识符占位,因此含括号或引号的文件名仍会变成对应的解析调用。双 face 包的 Node Client face 拒绝资源 import。浏览器 face 是 `lib/assets/` 的唯一写入方:一次性构建会删除本次没有产出的文件,watch 重建则保留它们,因为 Host 仍在提供上一代 bundle,运行它的页面会请求那些文件名。
### Host 路由
@@ -40,7 +40,7 @@ plugins/@deepseek-ai/dsh-client-ui-chat/assets/running-whale%402x-06738fa29b2f.p
### WebWorker 预览
预览页的 Host 运行在 Worker 中,页面对引用发出的请求到达不了它。在任何 Client bundle 运行之前,页面向 Worker 请求 `GET /__assets__`,它和 `GET /__boot__` 一样由隧道胶水层回答:镜像中每个声明了 `"./assets/*": "./lib/assets/*"` 的包,其 `lib/assets/` 下的所有文件打成一个未压缩的 tar,条目以引用命名。页面把每个条目按扩展名的媒体类型转成 Blob URL,它的 `resolveAsset` 返回这个 URL;镜像里没有对应文件时返回引用本身。基础镜像本来就带着这些文件,所以这个归档只跨越线程边界。
预览页的 Host 运行在 Worker 中,页面对引用发出的请求到达不了它。在任何 Client bundle 运行之前,页面向 Worker 请求 `GET /__assets__`,它和 `GET /__boot__` 一样由隧道胶水层回答:镜像中每个声明了 `"./assets/*": "./lib/assets/*"` 的包,其 `lib/assets/` 下的所有文件打成一个未压缩的 tar,条目以引用命名。页面把每个条目按扩展名的媒体类型转成 Blob URL,它的 `resolveAsset` 返回这个 URL;镜像里没有对应文件时返回引用本身。基础镜像本来就带着这些文件,所以不增加网络请求;但每次启动仍会遍历镜像中的包、打包全部资源,并把每个条目复制成 Blob,不论包是否启用。
### 变化与包 manifest
+4 -4
View File
@@ -12,8 +12,8 @@
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: 3208a452c2d86581
zh: f6373dd660c687c1
en: b1df1bf2b8ee3ad1
zh: 6373d1b8cae22d02
+6 -4
View File
@@ -99,7 +99,7 @@ Each reconciliation reads the package manifest and the bundle metadata again. A
## 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)` 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.
`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.
@@ -155,7 +155,8 @@ clientPath(id: string): string | undefined
* 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.
* 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.
*/
@@ -181,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.
*/
+6 -4
View File
@@ -99,7 +99,7 @@ interface WebBootGraph {
## 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 安装属于独立流程。
@@ -155,7 +155,8 @@ clientPath(id: string): string | undefined
* 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.
* 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.
*/
@@ -181,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.
*/
+2 -2
View File
@@ -9,8 +9,8 @@
en: dc8c41fbf9869179
zh: e45976a585cb9c86
/web-ui-style-reference/component-rules:
en: e82cec5fb79a9efd
zh: e4ea1a58d80a1548
en: 6715806a6f84d141
zh: 94b7a6ede4919512
/web-ui-style-reference/corner-radii-and-settings-cards:
en: 118972f6c2bd6ce4
zh: 85f8416b03e4dd8b
+1 -1
View File
@@ -20,7 +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; stylesheets from dependencies are not rewritten.
- 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.
+1 -1
View File
@@ -20,7 +20,7 @@
- 字体大小必须与行高配对;已有角色匹配时使用主题排版变量。
- 当组件约定要求保留列结构时,源码文本、终端输出和 diff 行不得换行;使用共享滚动条样式,不得定义组件专用滚动条选择器。
- 呈现规则写在 CSS 中。React 内联样式可以传递组件局部自定义属性值,但不得编码主题分支。
- 插件样式表中的相对 `url()` 指向本包 `src/client/assets/` 中的文件,构建会将其改写为[包资源](../packages/client/AGENTS.md#dependency-declaration) URL。`data:`、`#fragment`、以 `/` 开头、带 scheme 的引用以及空 `url('')` 引用保持不变;依赖包中的样式表不会被改写。
- 插件样式表中的相对 `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。
+2 -2
View File
@@ -18,8 +18,8 @@
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: 010c46ed0b12279a
zh: 2a04edbcd2e59508
+1
View File
@@ -33,6 +33,7 @@ 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. 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.
+1
View File
@@ -33,6 +33,7 @@ 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 始终对应一份内容。除非页面的载体提供了 `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 在取消时关闭文件。
+4 -2
View File
@@ -722,7 +722,8 @@ export class ClientModuleRegistry extends Service {
* 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.
* 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.
*/
@@ -781,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.
*/
+29 -13
View File
@@ -524,20 +524,29 @@ function packageAssetCall(reference: string): string {
return `${PACKAGE_ASSET_CALL}(${JSON.stringify(reference)})`
}
/**
* Placeholder the `url()` rewrite writes into stylesheet text instead of a
* reference: letters, digits, and underscores, which lightningcss never escapes,
* so a file name with parentheses or quotes still maps back to its reference.
*/
const STYLESHEET_ASSET_PLACEHOLDER = /__dshPackageAsset\d+__/gu
/**
* Compile stylesheet text into a JavaScript expression that passes each package-asset
* reference the `url()` rewrite inserted through the resolver call.
* @param css - stylesheet text after the rewrite.
* @param references - the references the rewrite inserted into this stylesheet.
* @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: ReadonlySet<string>): string {
function stylesheetSource(css: string, references: ReadonlyMap<string, string>): string {
if (references.size === 0) return JSON.stringify(css)
const inserted = new RegExp([...references].map(escapeSpecifier).join('|'), 'gu')
const parts: string[] = []
let last = 0
for (const match of css.matchAll(inserted)) {
parts.push(JSON.stringify(css.slice(last, match.index)), packageAssetCall(match[0]))
for (const match of css.matchAll(STYLESHEET_ASSET_PLACEHOLDER)) {
const reference = references.get(match[0])
/* v8 ignore next -- only the rewrite writes placeholders, and it records each one. */
if (reference === undefined) throw new Error(`client bundle compiler: unknown stylesheet asset placeholder ${match[0]}`)
parts.push(JSON.stringify(css.slice(last, match.index)), packageAssetCall(reference))
last = match.index + match[0].length
}
parts.push(JSON.stringify(css.slice(last)))
@@ -548,8 +557,8 @@ function stylesheetSource(css: string, references: ReadonlySet<string>): string
interface StylesheetAssets {
/** lightningcss options; empty when the stylesheet lies outside the package's own `src`. */
readonly options: { readonly visitor?: Visitor<CustomAtRules> }
/** References inserted during the transform that uses {@link options}. */
readonly references: ReadonlySet<string>
/** 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. */
@@ -599,8 +608,12 @@ function packageAssets(manifest: PackageManifest, packageDir: string): PackageAs
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))) {
throw new Error(`tsdown: ${name}: ${file} has a path segment containing %, #, ?, \\ or NUL, which the asset route cannot serve; rename it`)
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(
@@ -651,7 +664,7 @@ function packageAssets(manifest: PackageManifest, packageDir: string): PackageAs
},
},
urlRewrite(context, stylesheet) {
const inserted = new Set<string>()
const inserted = new Map<string, string>()
if (pathBelow(realDirectory(sourceDir), realpathSync(stylesheet)) === undefined) return { options: {}, references: inserted }
const base = dirname(stylesheet)
return {
@@ -660,9 +673,12 @@ function packageAssets(manifest: PackageManifest, packageDir: string): PackageAs
Url(url) {
// Empty values, fragments, root-relative paths, and schemes such as `data:` name no package file.
if (url.url === '' || url.url.startsWith('#') || url.url.startsWith('/') || URL_SCHEME.test(url.url)) return
const resolved = reference(context, resolvePath(base, url.url))
inserted.add(resolved)
return { ...url, url: resolved }
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${String(inserted.size)}__`
inserted.set(placeholder, reference(context, resolvePath(base, url.url)))
return { ...url, url: placeholder }
},
},
},
@@ -155,8 +155,7 @@ 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 resolveAsset = await tunnel.packageAssets()
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),
@@ -47,7 +47,8 @@ function packageDirectories(vfs: MemoryVfs, modules: string): string[] {
* 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 by their document-relative reference.
* @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> = {}
@@ -60,9 +61,7 @@ export function packPackageAssets(vfs: MemoryVfs, root: string): Uint8Array {
const assets = join(directory, 'lib', 'assets')
if (!vfs.existsSync(assets)) continue
for (const path of filesBelow(vfs, assets)) {
// The same per-segment encoding the Client build writes into each reference.
const reference = `plugins/${manifest.name}/assets/${path.split('/').map(encodeURIComponent).join('/')}`
files[reference] = vfs.readFileSync(join(assets, path)) as Uint8Array
files[`plugins/${manifest.name}/assets/${path}`] = vfs.readFileSync(join(assets, path)) as Uint8Array
}
}
return packTar(files)
@@ -77,9 +76,13 @@ export function packPackageAssets(vfs: MemoryVfs, root: string): Uint8Array {
export function packageAssetResolver(archive: Uint8Array): (reference: string) => string {
const urls = new Map<string, string>()
for (const entry of parseTar(archive)) {
const extension = entry.name.slice(entry.name.lastIndexOf('.')).toLowerCase()
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 reference = `plugins/${name}/assets/${path.split('/').map(encodeURIComponent).join('/')}`
const extension = path.slice(path.lastIndexOf('.')).toLowerCase()
// A copy detaches the Blob from the archive buffer the entry views.
urls.set(entry.name, URL.createObjectURL(new Blob([new Uint8Array(entry.bytes)], { type: ASSET_MEDIA_TYPES.get(extension) ?? '' })))
urls.set(reference, URL.createObjectURL(new Blob([new Uint8Array(entry.bytes)], { type: ASSET_MEDIA_TYPES.get(extension) ?? '' })))
}
return reference => urls.get(reference) ?? reference
}
@@ -1,5 +1,8 @@
// @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'
@@ -15,9 +18,9 @@ function seedPackage(vfs: MemoryVfs, name: string, exports: Record<string, strin
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 document-relative reference', () => {
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', './assets/*': './lib/assets/*' }, {
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',
@@ -30,16 +33,19 @@ it('archives every file of the packages that publish ./assets/* under its docume
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%402x-0123456789ab.png': 'png',
'plugins/@fixture/ui/assets/icons/whale@2x-0123456789ab.png': 'png',
})
expect(parseTar(packPackageAssets(new MemoryVfs(), ROOT))).toEqual([])
})
it('maps each archived reference to a typed Blob URL and leaves other references unchanged', async () => {
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()
seedPackage(vfs, '@fixture/ui', { './assets/*': './lib/assets/*' }, {
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',
...Object.fromEntries([...PACKAGE_ASSET_MEDIA_TYPES.keys()].map(extension => [`lib/assets/types/a${extension}`, extension])),
})
const blobs: Blob[] = []
const NativeURL = URL
@@ -52,7 +58,13 @@ it('maps each archived reference to a typed Blob URL and leaves other references
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)
}
// 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')
@@ -618,7 +618,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'async fetchBundle(request: Request): Promise<Response>',
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.',
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.',
},
@@ -636,7 +636,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
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.',
},
+28 -2
View File
@@ -280,12 +280,38 @@ describe('client bundle package asset imports', () => {
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'])('rejects %s, which the asset route cannot serve', async (path) => {
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 path segment containing %, #, ?, \\ or NUL, which the asset route cannot serve; rename it`)
.toThrow(`${join(root, path)} has a node_modules path segment or one containing %, #, ?, \\ or NUL, which the asset route cannot serve; rename it`)
})
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)
expect(stylesheetText(output, reference => `resolved:${reference}`))
.toContain(`resolved:plugins/@deepseek-ai/dsh-client-asset-fixture/assets/whale(2x)-${WHALE_REV}.png`)
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 () => {