mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
fix(client): tolerate minified stylesheet placeholders and document the asset reference encoding
This commit is contained in:
1 parent
991b2635ad
commit
7f3dd4ade0
14 files changed
+39
-19
No files matched your search
+2
-2
@@ -12,8 +12,8 @@
|
||||
en: 8ffcd355ec5e8271
|
||||
zh: 2932530bf783e3f7
|
||||
/agent-note-client-plugin-package-assets-served-by-url/decision/references-and-file-names:
|
||||
en: 69ca2e4ba27b2841
|
||||
zh: cb7d7c1bec1adf7b
|
||||
en: 704267953c123629
|
||||
zh: 860997024ad7825e
|
||||
/agent-note-client-plugin-package-assets-served-by-url/decision/build:
|
||||
en: da95b904bd60815f
|
||||
zh: 3a598bacc159abc4
|
||||
|
||||
@@ -24,7 +24,7 @@ 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 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.
|
||||
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.
|
||||
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ lib/assets/running-whale@2x-06738fa29b2f.png
|
||||
plugins/@deepseek-ai/dsh-client-ui-chat/assets/running-whale%402x-06738fa29b2f.png
|
||||
```
|
||||
|
||||
路径逐段做百分号编码,包名原样输出。URL 和其他应用自有路由一样相对外壳文档,在任意挂载下、以及在注入的 `<style>` 文本中都能正确解析。组件不需要 hook、注入值或 service:URL 就是模块首次运行时算出的常量。
|
||||
路径逐段先用 `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) 中登记,把每个扩展名声明为默认导出字符串的模块;有测试保证它与类型表一致。
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
en: cc0eb2c35a7a0998
|
||||
zh: 0e1df23869410c7a
|
||||
/deepseek-ai-dsh-client-modules/use-this-package/package-assets:
|
||||
en: 010c46ed0b12279a
|
||||
zh: 2a04edbcd2e59508
|
||||
en: 529af1eb8bf1acf2
|
||||
zh: 7d891a5dfa90c625
|
||||
/deepseek-ai-dsh-client-modules/use-this-package/what-the-browser-loads:
|
||||
en: 1383d68472b3c057
|
||||
zh: 94dd7d9abbdbccf3
|
||||
|
||||
@@ -36,7 +36,7 @@ A browser plugin package declares `dsh.client` in its `package.json` with `platf
|
||||
<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.
|
||||
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
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ kind: "package-reference"
|
||||
<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 在取消时关闭文件。
|
||||
同一个 `/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 在取消时关闭文件。
|
||||
|
||||
### 浏览器加载什么
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ function writeFixture(): string {
|
||||
writeFile(join(assets, 'icon.svg'), SVG)
|
||||
writeFile(join(assets, 'photo.png'), PNG)
|
||||
writeFile(join(assets, 'nested', 'a b.webp'), WEBP)
|
||||
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)
|
||||
@@ -205,6 +206,9 @@ describe('package asset route', () => {
|
||||
})
|
||||
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.body]).toEqual([200, 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)
|
||||
})
|
||||
|
||||
@@ -531,22 +531,19 @@ function packageAssetCall(reference: string): string {
|
||||
* a placeholder of letters, digits, and underscores, which lightningcss never escapes,
|
||||
* with a per-stylesheet random part that stylesheet text cannot contain by chance.
|
||||
* @param css - stylesheet text after the rewrite.
|
||||
* A placeholder may appear several times or not at all, as when minification copies
|
||||
* or drops a declaration.
|
||||
* @param references - reference of each placeholder the rewrite inserted into this stylesheet.
|
||||
* @returns a string literal, or a concatenation of literals and resolver calls.
|
||||
* @throws {Error} when an inserted placeholder does not appear exactly once.
|
||||
*/
|
||||
function stylesheetSource(css: string, references: ReadonlyMap<string, string>): string {
|
||||
if (references.size === 0) return JSON.stringify(css)
|
||||
const parts: string[] = []
|
||||
let last = 0
|
||||
const seen = new Set<string>()
|
||||
for (const match of css.matchAll(new RegExp([...references.keys()].join('|'), 'gu'))) {
|
||||
if (seen.has(match[0])) throw new Error(`client bundle compiler: stylesheet asset placeholder ${match[0]} appears twice`)
|
||||
seen.add(match[0])
|
||||
parts.push(JSON.stringify(css.slice(last, match.index)), packageAssetCall(references.get(match[0]) as string))
|
||||
last = match.index + match[0].length
|
||||
}
|
||||
if (seen.size !== references.size) throw new Error('client bundle compiler: a stylesheet asset placeholder is missing from the transformed stylesheet')
|
||||
parts.push(JSON.stringify(css.slice(last)))
|
||||
return parts.join(' + ')
|
||||
}
|
||||
|
||||
@@ -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 转换仅适用于服务端提供的文档。
|
||||
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -45,6 +45,7 @@ it('maps each archived file to a Blob URL under the reference and media type the
|
||||
'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[] = []
|
||||
@@ -63,6 +64,7 @@ it('maps each archived file to a Blob URL under the reference and media type the
|
||||
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')
|
||||
|
||||
@@ -291,6 +291,20 @@ describe('client bundle package asset imports', () => {
|
||||
.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 one minification drops', 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)
|
||||
expect(css).not.toContain('__dshPackageAsset')
|
||||
})
|
||||
|
||||
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,
|
||||
|
||||
Reference in new issue
Block a user