feat(client): resolve package assets through require.asset so the WebWorker preview can map them

This commit is contained in:
imccyu committed 2026-10-20 02:14:33 +08:00
1 parent 43a80da55c
commit c1d9f141b9
27 files changed
+438 -66

No files matched your search

@@ -9,17 +9,20 @@
en: 159de908b09780eb
zh: 8d15ca2f15b8846a
/agent-note-client-plugin-package-assets-served-by-url/decision:
en: 42d5a78134c944d5
zh: 5118bfc6b041a67f
en: 8ffcd355ec5e8271
zh: 2932530bf783e3f7
/agent-note-client-plugin-package-assets-served-by-url/decision/references-and-file-names:
en: 4f2daba2e01c9460
zh: 37177d73b3842255
en: 69ca2e4ba27b2841
zh: cb7d7c1bec1adf7b
/agent-note-client-plugin-package-assets-served-by-url/decision/build:
en: 1751930093d55cea
zh: 8e92e2de062edb96
/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
/agent-note-client-plugin-package-assets-served-by-url/decision/changes-and-package-manifests:
en: eedff52d04763c50
zh: 203bfcf81242b8fe
@@ -27,11 +30,11 @@
en: 1e4d83d0de2d47c9
zh: dc2c2876c73d3b1c
/agent-note-client-plugin-package-assets-served-by-url/alternatives-considered:
en: 23cca9b55c4252bd
zh: a7afb8d1dd943a8b
en: 98960e10634ee536
zh: f0fdb0b0cdf9511c
/agent-note-client-plugin-package-assets-served-by-url/consequences:
en: f2ec2d1773c3c57c
zh: cec2c0455ff26064
en: 898262f39c0243ae
zh: e30f2161f5b669fe
/agent-note-client-plugin-package-assets-served-by-url/testing:
en: b1b95226919615c6
zh: c928aedf738d2b40
en: d2ef6b4e95a9c6fd
zh: 7fcd77971bd20be5
@@ -12,11 +12,11 @@ The embedded bytes were downloaded and parsed with the JavaScript whether or not
## 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 document-relative URL; 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>`.
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 the same string. 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:
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/[email protected]
@@ -24,7 +24,7 @@ lib/assets/[email protected]
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.
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.
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.
@@ -38,6 +38,10 @@ The `dsh-package-asset` plugin of the dynamic browser face resolves an import ag
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 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.
### 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.
@@ -52,7 +56,7 @@ Images that third-party libraries load themselves (PDF.js fonts, cmaps, wasm, an
**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 runtime helper such as `require.asset(path)` on the factory `require`.** The URL is known at build time, so a helper only adds a nonstandard code-generation target; Vite and webpack asset modules also give an import a URL string.
**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.
@@ -71,11 +75,11 @@ Images that third-party libraries load themselves (PDF.js fonts, cmaps, wasm, an
## 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.
- The WebWorker preview reaches `/plugins` only through its tunnel, so `<img>` and CSS requests do not reach the Host running in the Worker; the WebWorker runtime owns serving them.
- `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; the Client module system README records it.
- 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.
@@ -83,6 +87,6 @@ Images that third-party libraries load themselves (PDF.js fonts, cmaps, wasm, an
- [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 and URLs, CSS rewriting and preserved forms, each build rejection, the Node face guard, one-shot pruning, and watch rebuilds that keep earlier files.
- [client-bundle-css.spec.ts](../../../../scripts/client-bundle-css.spec.ts) covers emitted names, resolver calls in scripts and stylesheet text, their CommonJS rendering, 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.
- [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.
@@ -12,11 +12,11 @@ Status: implemented
## Decision
Client 插件包把原始图片放在 `src/client/assets/`。共享预设把每个被引用的图片产出到 `lib/assets/`,并把引用改成文档相对 URL;包通过 `"exports": { "./assets/*": "./lib/assets/*" }` 和 `files` 里的 `lib/assets` 发布这些产物;client-modules 的 Host 在 `/plugins/<包名>/assets/<产出路径>` 提供它们。
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()` 引用,两者编译成同一个字符串。产出文件名在扩展名前插入内容 revision,即文件字节 SHA-1 摘要的前 12 位十六进制,因此一个 URL 永远只对应一份内容:
Client 代码用 import 引用图片(`import art from './assets/a.webp'`),包自己的样式表用相对 `url()` 引用,两者都编译成参数为同一个引用的 `require.asset(reference)` 调用;样式表变成字符串字面量与这些调用的拼接,在构建注入文本时执行。模块系统通过 `resolveAsset` 选项回答每次调用;除非载体提供了自己的实现,否则原样返回引用。产出文件名在扩展名前插入内容 revision,即文件字节 SHA-1 摘要的前 12 位十六进制,因此一个 URL 永远只对应一份内容:
```text
src/client/assets/[email protected]
@@ -24,7 +24,7 @@ lib/assets/[email protected]
plugins/@deepseek-ai/dsh-client-ui-chat/assets/running-whale%402x-06738fa29b2f.png
```
路径逐段做百分号编码,包名原样输出。URL 和其他应用自有路由一样相对外壳文档,在任意挂载下、以及在注入的 `<style>` 文本中都能正确解析。组件不需要 hook、注入值或 service: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) 中登记,把每个扩展名声明为默认导出字符串的模块;有测试保证它与类型表一致。
@@ -38,6 +38,10 @@ plugins/@deepseek-ai/dsh-client-ui-chat/assets/running-whale%402x-06738fa29b2f.p
被提供的资源每次请求都从磁盘流式读取,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,条目以引用命名。页面把每个条目按扩展名的媒体类型转成 Blob URL,它的 `resolveAsset` 返回这个 URL;镜像里没有对应文件时返回引用本身。基础镜像本来就带着这些文件,所以这个归档只跨越线程边界。
### 变化与包 manifest
图片重建后,重建的 `client.js` 带着新文件名,经现有的 bundle HMR 到达浏览器;资源没有单独的通道。client-modules 每次对账(即每次 Loader 条目构造和销毁)都读取包 manifest 和 bundle 元数据,不再把 manifest 缓存到重启,所以重装、升级或移动后重启的条目会发布当前的声明、client 导出和 manifest 路径;读到相同 manifest 和 bundle 元数据的对账不改动已发布的启动图,也不读取 bundle 字节。
@@ -52,7 +56,7 @@ plugins/@deepseek-ai/dsh-client-ui-chat/assets/running-whale%402x-06738fa29b2f.p
**保留 data URL。** 不需要路由,但字节随 JavaScript 一起传输和解析,每个在样式里用图片的包都要自写构建插件。
**运行时 helper,例如 factory `require` 上的 `require.asset(path)`。** URL 在构建时就已确定,helper 只多出一个非标准的代码生成目标;Vite 和 webpack asset modules 也是让 import 得到 URL 字符串。
**不带运行时 helper 的纯 URL 字符串。** 普通 Web 应用不需要更多,但 WebWorker 预览页无法请求这个 URL,事后改写只能把文件复制到静态部署,或在 bundle 文本里查找字符串替换。静态复制是构建时的快照,绕过了 Host 的规则,也覆盖不到运行时安装的包;在 bundle 文本里找字符串是在猜代码。同步调用让构建产物保持显式,并由各个载体决定 URL。
**Client Cordis service,例如 `ctx.assets.url(path)`。** 当前使用方都引用固定文件;运行时查找也拿不到内容 revision,除非另外发布每个包的资源清单。
@@ -71,11 +75,11 @@ plugins/@deepseek-ai/dsh-client-ui-chat/assets/running-whale%402x-06738fa29b2f.p
## Consequences
- ui-settings-account 的 `client.js` 为 214 KB,账户和 ui-chat 的 bundle 都不再含图片 data URL;图片显示时多一次同源请求,可能晚于布局出现。
- WebWorker 预览只经隧道到达 `/plugins`,`<img>` 和 CSS 请求到不了 Worker 内的 Host;由 WebWorker runtime 负责提供。
- `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/` 下指向包外的符号链接会被提供。
- 仓库外的包必须复刻产出文件名规则;Client 模块系统 README 记录了它。
- 仓库外的包必须复刻产出文件名规则和 `require.asset` 调用;Client 模块系统 README 记录了它们。
- 开发期间 `lib/assets/` 会留下旧的资源文件,直到下一次一次性构建把它们清掉;这种清理的前提是浏览器 face 是该目录的唯一写入方。
- 条目重启时会读取它的 manifest 并 stat 它的 bundle;只有结果变化时才读取 bundle 并广播启动图帧(69 行时约 30 KB)。只改 `package.json` 而条目没有重启时,仍要到下次条目重启或 Host 重启才生效。
@@ -83,6 +87,6 @@ plugins/@deepseek-ai/dsh-client-ui-chat/assets/running-whale%402x-06738fa29b2f.p
- [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) 覆盖产出文件名和 URL、CSS 改写与保留形式、每种构建拒绝、Node face 守卫、一次性构建的清理,以及 watch 重建保留旧文件。
- [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,并在浏览器中解码这些图片。
- [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) 覆盖归档和解析。
+15
View File
@@ -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 & {
@@ -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
+4 -4
View File
@@ -21,8 +21,8 @@
en: d4a7f865f4593724
zh: 4313eb8fe9cd5c98
/deepseek-ai-dsh-client-modules/use-this-package/package-assets:
en: 66e3e4425e8d0301
zh: c844a530edd5ef0b
en: 010c46ed0b12279a
zh: 2a04edbcd2e59508
/deepseek-ai-dsh-client-modules/use-this-package/what-the-browser-loads:
en: 1383d68472b3c057
zh: 94dd7d9abbdbccf3
@@ -69,8 +69,8 @@
en: ca75c51c89c2c9b0
zh: de54f3467ef3b9be
/deepseek-ai-dsh-client-modules/known-limitations-and-deferred-work:
en: 629b604d0fdacf6a
zh: 2b2e19077de3bd3e
en: 97127ddc35daf6d4
zh: 1417b06046b01067
/deepseek-ai-dsh-client-modules/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 3bb0c20c93f16f98
+1 -2
View File
@@ -35,7 +35,7 @@ A browser plugin package declares `dsh.client` in its `package.json` with `platf
### 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 turns the import into the document-relative reference `plugins/<package>/assets/<emitted path>`, so one URL always names one content. 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. 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
@@ -144,7 +144,6 @@ These limits define what the module system does not do. They are current package
- **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.
- **WebWorker preview cannot load package assets** — the preview reaches `/plugins` only through its tunnel, so an `<img>` or CSS `url()` request never reaches the Host running in the Worker; the WebWorker runtime owns serving them.
- **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>
+1 -2
View File
@@ -35,7 +35,7 @@ kind: "package-reference"
### 包资源
同一个 `/plugins` 路由也提供客户端包通过其 `"./assets/*": "./lib/assets/*"` 导出发布的图片。构建会产出每个被引用的资源,在扩展名之前插入其内容 revision(资源字节 SHA-1 摘要的前 12 位十六进制数字),并把 import 转换为文档相对引用 `plugins/<package>/assets/<emitted path>`,因此一个 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 始终对应一份内容。除非页面的载体提供了 `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 在取消时关闭文件。
### 浏览器加载什么
@@ -145,7 +145,6 @@ bundle 路由随注入的 `webServer` 生命周期注册:服务就绪时注册
- **惰性提供会保留已请求的 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 重启后才生效。
- **WebWorker 预览无法加载包资源**——预览只能经由其隧道访问 `/plugins`,因此 `<img>` 或 CSS `url()` 请求永远到达不了运行在 Worker 中的 Host;提供这些资源由 WebWorker 运行时负责。
- **Desktop 每次显示都重新读取包资源**——Desktop 转发器把每个 `/plugins/` 缓存头改写为 `no-store`,因此不可变的资源响应头到达不了其浏览器缓存。
<a id="dev-note"></a>
@@ -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
}
+4 -1
View File
@@ -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)
@@ -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') }),
+81 -18
View File
@@ -50,7 +50,10 @@ const INLINE_CSS_VIRTUAL_PREFIX = '\0dsh-inline-css:'
const CSS_VIRTUAL_SUFFIX = '.mjs'
const INLINE_CSS_QUERY = '?inline'
/** 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,
@@ -58,7 +61,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\');',
@@ -508,6 +511,47 @@ 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.
*/
const PACKAGE_ASSET_CALL = '__dshPackageAsset'
/** 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.
* @param css - stylesheet text after the rewrite.
* @param references - the references 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 {
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]))
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> }
/** References inserted during the transform that uses {@link options}. */
readonly references: ReadonlySet<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. */
@@ -516,10 +560,11 @@ interface PackageAssets {
* 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.
* @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): { readonly visitor?: Visitor<CustomAtRules> }
urlRewrite(context: AssetContext, stylesheet: string): StylesheetAssets
}
/**
@@ -527,7 +572,8 @@ interface PackageAssets {
* 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 becomes its document-relative URL.
* 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.
@@ -584,7 +630,15 @@ function packageAssets(manifest: PackageManifest, packageDir: string): PackageAs
},
load(id) {
if (!isAbsolute(id) || packageAssetMediaType(id) === undefined) return null
return `export default ${JSON.stringify(reference(this, id))};`
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
@@ -597,16 +651,22 @@ function packageAssets(manifest: PackageManifest, packageDir: string): PackageAs
},
},
urlRewrite(context, stylesheet) {
if (pathBelow(realDirectory(sourceDir), realpathSync(stylesheet)) === undefined) return {}
const inserted = new Set<string>()
if (pathBelow(realDirectory(sourceDir), realpathSync(stylesheet)) === undefined) return { options: {}, references: inserted }
const base = dirname(stylesheet)
return {
visitor: {
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
return { ...url, url: reference(context, resolvePath(base, url.url)) }
options: {
visitor: {
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 }
},
},
},
references: inserted,
}
},
}
@@ -780,18 +840,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,
...assets.urlRewrite(this, fileId),
...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',
@@ -806,8 +867,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, ...assets.urlRewrite(this, fileId) })
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',
@@ -821,8 +883,9 @@ 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, ...assets.urlRewrite(this, fileId) })
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))
},
}, assets.plugin],
outputOptions: {
+6 -5
View File
@@ -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
@@ -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
}
@@ -154,10 +156,12 @@ export async function connectWorkerHost(worker: Worker, options?: WorkerHostConn
(options?.overlays ?? []).map(overlay => new URL(overlay, document.baseURI).href),
)
const payload = await tunnel.bootPayload()
const resolveAsset = await 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,85 @@
/**
* 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 by their document-relative reference.
*/
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)) {
// 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
}
}
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 extension = entry.name.slice(entry.name.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) ?? '' })))
}
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 document-relative reference. */
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,63 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from 'vitest'
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 document-relative reference', () => {
const vfs = new MemoryVfs()
seedPackage(vfs, '@fixture/ui', { './client': './lib/client.js', './assets/*': './lib/assets/*' }, {
'lib/client.js': '',
'lib/assets/mark-0123456789ab.svg': '<svg/>',
'lib/assets/icons/[email protected]': '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%402x-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 () => {
const vfs = new MemoryVfs()
seedPackage(vfs, '@fixture/ui', { './assets/*': './lib/assets/*' }, {
'lib/assets/mark-0123456789ab.svg': '<svg/>',
'lib/assets/data-0123456789ab.bin': 'raw',
})
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 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[] = []
+1
View File
@@ -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. */
+34 -4
View File
@@ -3,6 +3,7 @@
* 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 { runInNewContext } from 'node:vm'
import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
@@ -30,6 +31,7 @@ interface BundlePlugin {
resolveId?: (source: string, importer?: string) => 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
}
type PluginName = 'dsh-css-modules-inline' | 'dsh-css-global-inline' | 'dsh-css-text-inline' | 'dsh-package-asset'
@@ -40,6 +42,20 @@ const WHALE = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1
const WHALE_REV = packageAssetRevision(WHALE)
const WHALE_URL = `plugins/@deepseek-ai/dsh-client-asset-fixture/assets/whale-${WHALE_REV}.png`
/**
* 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 body = output
.replace(/^export default (.*);$/mu, 'return typeof css === "undefined" ? $1 : css;')
.replace(/^export \{\};$/mu, 'return css;')
return runInNewContext(`(() => {\n${body}\n})()`, { __dshPackageAsset: resolve, document: undefined }) as string
}
const temporaryRoots: string[] = []
afterEach(async () => {
@@ -184,7 +200,7 @@ describe('client bundle package asset imports', () => {
const first = await load.call(context, id)
const second = await load.call(context, id)
const expected = `export default "plugins/@deepseek-ai/dsh-client-asset-fixture/assets/icons/whale%402x-${WHALE_REV}.png";`
const expected = `export default __dshPackageAsset("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([{
@@ -200,6 +216,18 @@ describe('client bundle package asset imports', () => {
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 { renderChunk } = browserPlugins(root)('dsh-package-asset')
if (renderChunk === undefined) throw new Error('asset plugin has no renderChunk hook')
const code = `const a = __dshPackageAsset("${WHALE_URL}");\nconst css = "x" + __dshPackageAsset("${WHALE_URL}") + "y";\n`
expect(renderChunk(code, {}, { format: 'cjs' })?.toString())
.toBe(`const a = require.asset("${WHALE_URL}");\nconst css = "x" + require.asset("${WHALE_URL}") + "y";\n`)
expect(renderChunk(code, {}, { format: 'es' })).toBeNull()
expect(renderChunk('const a = 1;\n', {}, { format: 'cjs' })).toBeNull()
})
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'))
@@ -289,9 +317,11 @@ describe('client bundle stylesheet url()', () => {
const output = await loadImport(browserPlugins(root)('dsh-css-modules-inline'), context, './View.module.css', join(root, 'src/client/index.ts'))
expect(output).toContain(`url(${WHALE_URL})`)
expect(output).toContain(`url(" + __dshPackageAsset(${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(output).toContain(preserved)
expect(css).toContain(preserved)
}
expect(emittedFiles(context)).toEqual([{
type: 'asset',
@@ -313,7 +343,7 @@ describe('client bundle stylesheet url()', () => {
const output = await loadImport(browserPlugins(root)(name), context, source, join(root, 'src/client/index.ts'))
expect(output).toContain(`url(${WHALE_URL})`)
expect(stylesheetText(output, reference => `resolved:${reference}`)).toContain(`url(resolved:${WHALE_URL})`)
expect(context.emitted.map(file => file.fileName)).toEqual([`assets/whale-${WHALE_REV}.png`])
})