Merge pull request #7430 from deepseek-harness/revert-7413-perf/web-startup-assets

Revert "perf(web): 压缩动态插件资源以加快冷启动"
This commit is contained in:
imccyu authored and GitHub committed 2026-10-27 23:27:45 +08:00
commit c232e618fd
16 files changed
+66 -301

No files matched your search

+25 -10
View File
@@ -17,7 +17,7 @@
import type { ChildProcess } from 'node:child_process'
import { spawn } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { createServer } from 'node:http'
import { createRequire, SourceMap } from 'node:module'
import { tmpdir } from 'node:os'
@@ -27,7 +27,7 @@ import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import WebSocket from 'ws'
import { REPO_ROOT, newEnglishPage, probeFreePort, requireDist, saveFailureShot } from './support.ts'
import { REPO_ROOT, connectFreshWorkspace, newEnglishPage, probeFreePort, requireDist, saveFailureShot } from './support.ts'
const WEB_SURFACE_PROMPT = fileURLToPath(new URL('./expected/web-runtime-context/web-surface-prompt.expected.md', import.meta.url))
/** Framed user text that marks the auxiliary title request's own messages. */
@@ -284,18 +284,31 @@ async function screen(page: Page, name: string): Promise<void> {
/** First column track (px string) of the frame grid. */
async function firstTrack(page: Page): Promise<string> {
return (await page.locator('[class*="frame"]').first().evaluate(
return (await page.locator('[class*="frame"]').evaluate(
el => getComputedStyle(el).gridTemplateColumns)).split(' ')[0]!
}
/** Last column track (details) as a number of pixels. */
async function detailsTrack(page: Page): Promise<number> {
const cols = await page.locator('[class*="frame"]').first().evaluate(
const cols = await page.locator('[class*="frame"]').evaluate(
el => getComputedStyle(el).gridTemplateColumns)
return Number(cols.split(' ').pop()!.replace('px', ''))
}
// Readiness gate: `dsh web` serves every production manifest plugin; until every UI
// plugin's client bundle exists and exports apply, the loader fail-louds and
// the frame never appears.
const UI_PLUGIN_DIRS = [
'connection', 'ui-theme', 'locale', 'ui-layout', 'ui-renderer', 'ui-session', 'ui-sidebar',
'ui-settings', 'ui-settings-general', 'ui-settings-models', 'ui-conversation', 'ui-approval', 'ui-chat',
'ui-model-selection', 'ui-user-questions', 'ui-trajectory', '../session-query/session-log-export',
]
const ROUND_DONE_MARKER = 'WEB_ROUND_DONE'
const notReady = UI_PLUGIN_DIRS.filter((dir) => {
const bundle = join(REPO_ROOT, 'packages/client', dir, 'lib/client.js')
return !existsSync(bundle) || !readFileSync(bundle, 'utf8').includes('exports.apply')
})
if (notReady.length > 0) console.warn(`[smoke-real] skipped — client bundles not ready: ${notReady.join(', ')}`)
describe('dsh web keyless CLI smoke', () => {
it('serves a usable app from three immutable plugin batches', async () => {
@@ -673,7 +686,7 @@ describe('dsh web keyless CLI smoke', () => {
})
})
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('web smoke (real host, real key)', () => {
describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke (real host, real key)', () => {
let child: ChildProcess
let sessionsDir: string
let baseUrl: string
@@ -717,9 +730,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('web smoke (real host, real key)'
page = await newEnglishPage(browser)
page.on('pageerror', e => pageErrors.push(String(e)))
await page.goto(baseUrl, { waitUntil: 'load' })
const welcome = page.getByRole('dialog', { name: 'Preview Notice' })
await welcome.getByRole('button', { name: 'Continue' }).click()
await welcome.waitFor({ state: 'detached' })
}, 120_000)
afterAll(async () => {
@@ -737,13 +747,18 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('web smoke (real host, real key)'
onTestFailed(() => saveFailureShot(page, 'w5-cold-start'))
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
expect(await page.locator('text=Failed to load plugins').count()).toBe(0)
const template = await page.locator('[class*="frame"]').first().evaluate(el => getComputedStyle(el).gridTemplateColumns)
const template = await page.locator('[class*="frame"]').evaluate(el => getComputedStyle(el).gridTemplateColumns)
expect(template.split(' ').length).toBe(3)
await screen(page, '01-cold-start')
})
it('empty-state first send completes a real model round', async () => {
onTestFailed(() => saveFailureShot(page, 'w5-first-round'))
// This scenario spawns its own server against a fresh $DSH_HOME with the
// DeepSeek credential inherited from the environment, so no onboarding
// step mounts and the page is immediately interactive.
// Fresh world: connect a Workspace so the composer starts live.
await connectFreshWorkspace(page, sessionsDir)
const input = page.locator('[data-composer-input]').first()
await input.waitFor({ timeout: 10_000 })
const productTitle = await page.title()
@@ -824,7 +839,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('web smoke (real host, real key)'
it('sidebar drag widens the column and resets across reload', async () => {
onTestFailed(() => saveFailureShot(page, 'w5-drag'))
const before = await firstTrack(page)
const handle = page.locator('[data-side="sidebar"]')
const handle = page.locator('[class*="handle"]').first()
const box = await handle.boundingBox()
expect(box).not.toBeNull()
await page.mouse.move(box!.x + box!.width / 2, box!.y + 300)
+1 -1
View File
@@ -85,7 +85,7 @@ A dynamic browser half either carries a module privately or requests the shared
3. **Silence means a private copy.** Third-party implementation libraries outside the baseline may be bundled independently. A value reached only through `import type` is erased and creates no request.
4. **A request has two possible suppliers.** A dynamic package supplies its own row; `PLATFORM_MODULES` supplies an exact static-table key. There is no `dsh.client.provide` alias protocol.
5. **Validate both sides.** The dynamic build preset externalizes the baseline and rejects undeclared workspace value imports; [`verify-client-packages`](../../scripts/verify-client-packages.ts) rejects malformed or redundant requests, missing suppliers, and synchronous request cycles.
6. **Package-local dynamic chunks are self-contained.** Source uses `import()` and the shared preset calls the factory loader's `.async` method with a literal `./client.<name>.js` path; an entry or chunk must not retain a synchronous relative `require("./client*.js")`. Keep every static relative dependency inside its owning output chunk rather than relying on a sibling-chunk graph the runtime does not support.
6. **Package-local dynamic chunks are self-contained.** Source uses `import()` and the shared preset emits `require.async("./client.<name>.js")`; an entry or chunk must not retain a synchronous relative `require("./client*.js")`. Keep every static relative dependency inside its owning output chunk rather than relying on a sibling-chunk graph the runtime does not support.
### The module graph sits below cordis DI
+4 -4
View File
@@ -24,8 +24,8 @@
en: 529af1eb8bf1acf2
zh: 7d891a5dfa90c625
/deepseek-ai-dsh-client-modules/use-this-package/what-the-browser-loads:
en: 3c372e9d65e294a8
zh: 00f82efab7071258
en: 1383d68472b3c057
zh: 94dd7d9abbdbccf3
/deepseek-ai-dsh-client-modules/use-this-package/live-plugin-composition:
en: 45740a56e931d094
zh: fa44cf77c86019af
@@ -36,8 +36,8 @@
en: 1778b5a63ecde5e8
zh: 537b4f8c755af8ad
/deepseek-ai-dsh-client-modules/use-this-package/build-requirements:
en: 15aa99a9df72e51e
zh: 3324596fb5d3588d
en: b6a4d3b901c89efe
zh: 615b5f3eec41f281
/deepseek-ai-dsh-client-modules/understand-the-implementation:
en: 7ab11d2c2bdc6eaa
zh: 54c3c9091cf59711
+1 -3
View File
@@ -40,7 +40,7 @@ The same `/plugins` route serves the images a client package publishes through i
### What the browser loads
The application combo scripts carry only each plugin's `client.js` entry and register those factories once during boot; module bodies remain lazy and run only at first import or materialization. A source `import()` split by tsdown calls the factory loader's `.async` method with a literal `./client.<name>.js` path; its versioned sibling script arrives only when that expression runs. Rows that share a combo URL share one in-flight script task. A combo whose `<script>` fails to load is requested once more; a combo that loads without registering a row is never re-executed, because the batch script registers its packages in sequence and a replay would stop at the first duplicate registration. In both cases each still-missing row then loads its own one-resource combo URL, so one failed batch costs at most three requests per missing row and leaves the rows it did register untouched. The module system records the last import failure per row (transport, registration, dependency cascade, or factory execution); the Web boot audit reports that text per entry. HMR switches one changed row to its revisioned one-resource combo URL. `<id>/client` and the bare id resolve to the same exports, because a plugin bundle is its package's client half.
The application combo scripts carry only each plugin's `client.js` entry and register those factories once during boot; module bodies remain lazy and run only at first import or materialization. A source `import()` split by tsdown compiles to `require.async("./client.<name>.js")`; its versioned sibling script arrives only when that expression runs. Rows that share a combo URL share one in-flight script task. A combo whose `<script>` fails to load is requested once more; a combo that loads without registering a row is never re-executed, because the batch script registers its packages in sequence and a replay would stop at the first duplicate registration. In both cases each still-missing row then loads its own one-resource combo URL, so one failed batch costs at most three requests per missing row and leaves the rows it did register untouched. The module system records the last import failure per row (transport, registration, dependency cascade, or factory execution); the Web boot audit reports that text per entry. HMR switches one changed row to its revisioned one-resource combo URL. `<id>/client` and the bare id resolve to the same exports, because a plugin bundle is its package's client half.
### Live plugin composition
@@ -58,8 +58,6 @@ The shell seeds a frozen module table ([`PLATFORM_MODULES`](../web/README.md#the
The host serves built client bundles, so `pnpm run build` must have produced each `lib/client.js` before launch; a missing bundle fails activation loudly with one build instruction and a package/path list. Source launch maps host imports to TypeScript source but still consumes the built client export. The package accepts no plugin config of its own.
The shared Client preset minifies browser factories and lazy chunks unless `NODE_ENV=development`. Minification preserves the function and class names produced by bundling, legal comments, full package license banners, and source maps with original source content. `pnpm run dev:web` and its HMR rebuilds inherit `NODE_ENV`; set it explicitly to `development` for unminified output. Host and Node Client output use separate presets.
-----
<a id="understand-the-implementation"></a>
+1 -3
View File
@@ -40,7 +40,7 @@ kind: "package-reference"
### 浏览器加载什么
application combo 脚本只携带每个插件的 `client.js` 入口,并在启动时仅注册一次这些 factory;模块主体仍保持惰性,只在首次 import 或物化时运行。经 tsdown 拆分的源码 `import()` 会以字面量 `./client.<name>.js` 路径调用 factory loader 的 `.async` 方法;只有执行该表达式时,对应的带版本同级脚本才会到达。共享 combo URL 的 row 共用一个进行中的脚本任务。`<script>` 加载失败的 combo 会再请求一次;加载成功但没有注册某条 row 的 combo 绝不会重新执行,因为批量脚本按顺序注册各个包,重放会在第一个重复注册处停止。两种情况下,每条仍缺失的 row 随后加载自己的单资源 combo URL,因此一个失败的 batch 对每条缺失 row 最多花费三次请求,且不影响它已经注册的 row。模块系统按 row 记录最后一次 import 失败(传输、注册、依赖级联或 factory 执行);Web 启动审计按条目报告该文本。HMR(热模块替换)会让一条发生变化的 row 改用带 revision 的单资源 combo URL。`<id>/client` 与裸 id 解析到同一组导出,因为插件 bundle 就是其包的客户端半侧。
application combo 脚本只携带每个插件的 `client.js` 入口,并在启动时仅注册一次这些 factory;模块主体仍保持惰性,只在首次 import 或物化时运行。经 tsdown 拆分的源码 `import()` 会编译为 `require.async("./client.<name>.js")`;只有执行该表达式时,对应的带版本同级脚本才会到达。共享 combo URL 的 row 共用一个进行中的脚本任务。`<script>` 加载失败的 combo 会再请求一次;加载成功但没有注册某条 row 的 combo 绝不会重新执行,因为批量脚本按顺序注册各个包,重放会在第一个重复注册处停止。两种情况下,每条仍缺失的 row 随后加载自己的单资源 combo URL,因此一个失败的 batch 对每条缺失 row 最多花费三次请求,且不影响它已经注册的 row。模块系统按 row 记录最后一次 import 失败(传输、注册、依赖级联或 factory 执行);Web 启动审计按条目报告该文本。HMR(热模块替换)会让一条发生变化的 row 改用带 revision 的单资源 combo URL。`<id>/client` 与裸 id 解析到同一组导出,因为插件 bundle 就是其包的客户端半侧。
### 插件动态组合
@@ -59,8 +59,6 @@ application combo 脚本只携带每个插件的 `client.js` 入口,并在启
宿主提供的是已构建的客户端 bundle,因此启动前 `pnpm run build` 必须已产出每个 `lib/client.js`;缺失 bundle 会明确导致激活失败,并给出一条构建说明及包/路径列表。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费这一构建后的客户端导出。本包自身不接受任何插件配置。
除 `NODE_ENV=development` 外,共享 Client 预设会压缩浏览器 factory 和懒加载 chunk。压缩会保留打包生成的函数名和类名、法律声明注释、完整的包许可证声明,以及包含原始源码内容的 source map。`pnpm run dev:web` 及其 HMR 重建会继承 `NODE_ENV`;要获得未压缩产物,需显式将其设为 `development`。Host 和 Node Client 产物使用各自的预设。
-----
<a id="understand-the-implementation"></a>
+5 -11
View File
@@ -120,8 +120,6 @@ function browserSourcePath(source: string, sourcemapPath: string): string {
* the package without lib/index.js and the host Loader cannot import its node
* half. The Client build consumes `lib/types` and chains those tsc maps, with
* original source content, into the standalone plugin map.
* Browser factories and lazy chunks are minified unless NODE_ENV is development;
* minification retains bundled function/class names and legal notices.
* @param packageDir - the package's own directory, normally the config's
* `import.meta.dirname`. Its `package.json` name is the plugin id stamped into
* the __ModuleLoader__.load handoff and onto the injected style tags, and the
@@ -792,12 +790,6 @@ function clientConfig(
outDir: 'lib',
format: 'cjs',
platform: 'browser',
minify: process.env.NODE_ENV === 'development' ? false : {
// Cordis uses function/class names for plugin and callback labels.
compress: { keepNames: { function: true, class: true } },
mangle: { keepNames: true },
codegen: { legalComments: 'inline' },
},
// Types ship from lib/types (tsc); dts here would wrap the banner/footer into .d.cts and break parsing.
dts: false,
// Plugin code is fetched outside Vite's module graph, so its own bundle
@@ -935,9 +927,11 @@ function clientConfig(
isolation.sourcePath(source, mapPath)
return browserSourcePath(source, mapPath)
},
banner: chunk => `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, ${chunk.isEntry ? '' : `chunk: ${JSON.stringify(chunk.fileName)}, `}factory: (require) => {`,
// Full license notices may include ordinary comments that minification removes.
postBanner: chunk => clientBanner?.(chunk.fileName) ?? '',
banner: (chunk) => {
const registration = `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, ${chunk.isEntry ? '' : `chunk: ${JSON.stringify(chunk.fileName)}, `}factory: (require) => {`
const prefix = clientBanner?.(chunk.fileName)
return prefix === undefined ? registration : `${prefix}\n${registration}`
},
footer: 'return module.exports; } });',
intro: 'var module = { exports: {} }; var exports = module.exports;',
},
@@ -10,7 +10,6 @@ import { describe, expect, it } from 'vitest'
const packageRoot = resolve(import.meta.dirname, '..')
const bundlePath = join(packageRoot, 'lib/client.js')
const pdfChunkPath = join(packageRoot, 'lib/client.pdf.js')
const syncChunkCall = /(?<![\w$.])[$A-Z_a-z][\w$]*\s*\(\s*(["'`])\.\/client[^"'`/]*\.js\1\s*\)/u
const require = createRequire(import.meta.url)
const licenseNames = [
'LICENSE',
@@ -59,15 +58,16 @@ describe('published document preview licenses', () => {
const client = run('tar', ['-xOf', resolve(packageRoot, packed.filename), 'package/lib/client.js'], packageRoot, task.timeout)
const pdf = run('tar', ['-xOf', resolve(packageRoot, packed.filename), 'package/lib/client.pdf.js'], packageRoot, task.timeout)
expect([...client.matchAll(/\.async\s*\(\s*(["'`])(\.\/client[^"'`/]*\.js)\1\s*\)/gu)].map(match => match[2]))
expect([...client.matchAll(/require\.async\("(\.\/client[^"/]*\.js)"\)/gu)].map(match => match[1]))
.toEqual(['./client.frontmatter-fields.js', './client.pdf.js', './client.excel.js'])
expect(client).not.toMatch(syncChunkCall)
expect(pdf).not.toMatch(syncChunkCall)
expect(client).not.toMatch(/\brequire\("\.\/client[^"/]*\.js"\)/u)
expect([...pdf.matchAll(/require\("(\.\/client[^"/]*\.js)"\)/gu)].map(match => match[1]))
.toEqual([])
expect(client).not.toContain('//! Bundled PDF.js license notices')
expect(client).not.toContain('/pdfjs-dist/')
expect(pdf).toContain('//! Bundled PDF.js license notices')
const excel = run('tar', ['-xOf', resolve(packageRoot, packed.filename), 'package/lib/client.excel.js'], packageRoot, task.timeout)
expect(excel).not.toMatch(syncChunkCall)
expect(excel).not.toMatch(/\brequire\("\.\/client[^"/]*\.js"\)/u)
expect(client).not.toContain('FortuneSheet')
expect(excel).toContain('//! Bundled spreadsheet license notices')
expect(excel).toContain('Copyright (c) 2022 Suzhou Ruilisi Technology Co., Ltd')
@@ -78,7 +78,7 @@ describe('published document preview licenses', () => {
expect(excel).toContain(license.split('\n').map(line => `// ${line}`).join('\n'))
}
const frontmatter = run('tar', ['-xOf', resolve(packageRoot, packed.filename), 'package/lib/client.frontmatter-fields.js'], packageRoot, task.timeout)
expect(frontmatter).not.toMatch(syncChunkCall)
expect(frontmatter).not.toMatch(/\brequire\("\.\/client[^"/]*\.js"\)/u)
let frontmatterInitialized = false
runInNewContext(frontmatter, { window: { __ModuleLoader__: { load: (registration: {
factory: (resolve: (specifier: string) => unknown) => { FrontmatterFields: unknown }
@@ -5,20 +5,18 @@ import { describe, expect, it } from 'vitest'
const packageRoot = resolve(import.meta.dirname, '..')
const entryPath = join(packageRoot, 'lib/client.js')
const terminalPath = join(packageRoot, 'lib/client.terminal.js')
const syncChunkCall = /(?<![\w$.])[$A-Z_a-z][\w$]*\s*\(\s*(["'`])\.\/client[^"'`/]*\.js\1\s*\)/u
describe('terminal client artifacts', () => {
it.skipIf(!existsSync(entryPath))('keeps xterm outside the startup bundle', () => {
expect(existsSync(terminalPath)).toBe(true)
const entry = readFileSync(entryPath, 'utf8')
const terminal = readFileSync(terminalPath, 'utf8')
expect([...entry.matchAll(/\.async\s*\(\s*(["'`])(\.\/client[^"'`/]*\.js)\1\s*\)/gu)].map(match => match[2]))
expect([...entry.matchAll(/require\.async\("(\.\/client[^"/]*\.js)"\)/gu)].map(match => match[1]))
.toEqual(['./client.terminal.js'])
expect(entry).not.toMatch(syncChunkCall)
expect(terminal).not.toMatch(syncChunkCall)
const entryMap = JSON.parse(readFileSync(`${entryPath}.map`, 'utf8')) as { sources: string[] }
const terminalMap = JSON.parse(readFileSync(`${terminalPath}.map`, 'utf8')) as { sources: string[] }
expect(entryMap.sources.some(source => source.includes('/@xterm/xterm/'))).toBe(false)
expect(terminalMap.sources.some(source => source.includes('/@xterm/xterm/'))).toBe(true)
expect(entry).not.toMatch(/\brequire\("\.\/client[^"/]*\.js"\)/u)
expect([...terminal.matchAll(/require\("(\.\/client[^"/]*\.js)"\)/gu)].map(match => match[1]))
.toEqual([])
expect(entry).not.toContain('/@xterm+xterm@')
expect(terminal).toContain('/@xterm+xterm@')
})
})
@@ -15,8 +15,8 @@
en: 4e53164159b5ce41
zh: 89582dc0294d0776
/deepseek-ai-dsh-experimental-webworker-packer/use-this-package:
en: 2c7aff0894ebf4fa
zh: cce7ca3aa5eeac33
en: 6a4ec925ea6681f5
zh: 4af12d1d3de38dbd
/deepseek-ai-dsh-experimental-webworker-packer/model-experience:
en: e16b355530dadc1c
zh: 8488e03b253b7b33
@@ -32,7 +32,7 @@ The pack is a three-layer standard stack:
1. **Roster** — the shipped profile template's bundles and every bundle in `OPTIONAL_BUNDLES` from [`dsh-official-bundles`](../../util/official-bundles/README.md), each resolved from the CLI manifest as the launcher resolves it, plus every package their patch files name (insert lists and grouped rows included), plus the rows of every config tree the CLI declares in its `package.json` `dsh.configTrees`, materialized as a Node-style dependency closure. External peer edges never bind the worker; workspace peers stay on the chain. The CLI manifest itself enters the image alone at `node_modules/@deepseek-ai/dsh/package.json`, as the worker's bundle resolution anchor; the manifest records its name and the profile.
2. **Publish view** — each workspace or vendored package contributes its built npm slice (`files` through picomatch) without source or workspace `dist/`. External packages retain published JavaScript under both `src/` and `dist/` because their `main` or `exports` may point there; only generic test, map, declaration, and archive exclusions apply.
3. **Reachability sweep** — the runtime loader's own resolution walks from every non-wildcard workspace runtime export plus the worker assembly's seeds (`IMAGE_ENTRY_SEEDS`), lowering each reached module to the wrapper contract at pack time. Declaration-only condition trees do not seed the sweep; runtime requests for those exports still fail. The transform reports statically named imports, re-exports, and dynamic imports; calls through `require`; and module-scope direct calls of the form `createRequire(import.meta.url)('pkg')` through a named import from `node:module` or `module`, including an import alias. Page assets (`lib/client.js` behind `./client` exports) ship verbatim; an unresolvable request from our own code fails the pack, third-party ones are tolerated to fail loud at require time.
4. **Lazy chunks** — every page chunk requested through the Client factory's injected loader `.async` method with a literal `./client.<name>.js` path leaves the base image unless a `./client` export names it. The scanner accepts the production build's renamed loader variable and single, double, or backtick quotes. Each chunk becomes its own deterministic archive named `lazy/<sha256 of the archive>.tar.gz` beside the base image, and the manifest registers its path, size, and archive for the worker's lazy filesystem. A request for a chunk the package does not publish fails the pack.
4. **Lazy chunks** — every page chunk a page asset requests as a literal `require.async("./client.<name>.js")`, the only form the dynamic Client build emits, leaves the base image unless a `./client` export names it. Each becomes its own deterministic archive named `lazy/<sha256 of the archive>.tar.gz` beside the base image, and the manifest registers its path, size, and archive for the worker's lazy filesystem. A request for a chunk the package does not publish fails the pack.
`repository.ts` owns the repo-shaped inputs (workspace scan of `vendor/`, `packages/`, `native/system/packages/`, and `apps/`; the profile roster through the shipped templates and optional-bundle list of `dsh-official-bundles` and app-boot's bundle patch readers; the CLI manifest as the installation; the home patch, which sets the runtime's `PLATFORM_ENTRY_CONFIG` fields over each named row's config composed from the template's bundle layers); `pack.ts` owns none of them, so the same library packs a different tree by being called differently. The native scan makes the Landlock entry package an ordinary published-view dependency while its executable remains a Worker platform implementation. The CLI is `dsh-pack-vfs-image --out <file> [--profile web]`; it writes the lazy archives beside the base image, and `apps/web`'s `build:preview` runs it after the preview shell build.
@@ -32,7 +32,7 @@ VFS 镜像打包器:把一份随附 profile 的 bundle 和安装提供的 opti
1. **Roster**——随附 profile 模板的 bundle 和 [`dsh-official-bundles`](../../util/official-bundles/README.zh.md) `OPTIONAL_BUNDLES` 中的每个 bundle(各自按启动器的方式从 CLI manifest 解析),加上它们 patch 文件点名的每个包(含 insert 列表与分组行),再加上 CLI(命令行界面)在 `package.json` `dsh.configTrees` 里声明的每棵配置树的行,按 Node 式依赖闭包物化。外部 peer 边绝不会绑定到 worker,workspace peer 保留在链上。CLI manifest 本身单独进入镜像,位于 `node_modules/@deepseek-ai/dsh/package.json`,作为 worker 解析 bundle 的锚点;manifest 记录它的名称和 profile。
2. **发布视图**——每个 workspace 或 vendored 包贡献其构建后的 npm 切片(`files` 走 picomatch),不带源码和 workspace `dist/`。外部包的 `main` 或 `exports` 可能指向 `src/` 或 `dist/`,因此两处发布 JavaScript 都会保留,只应用通用的测试、map、声明与归档排除规则。
3. **可达性 sweep**——用运行时加载器自己的解析, 从全部不含通配符的 workspace 运行时导出加 worker 装配种子 (`IMAGE_ENTRY_SEEDS`) 出发, 打包时把每个可达模块转换为符合包装层约定的形式. 仅含类型声明的条件树不作为扫描入口; 运行时代码请求这些导出仍会失败. 该转换会报告名称可静态确定的 import、re-export 与动态 import、经 `require` 发起的调用, 以及通过 `node:module` 或 `module` 具名导入 (含导入别名) 在模块作用域直接发起的 `createRequire(import.meta.url)('pkg')` 调用. 页面资产 (`./client` 导出背后的 `lib/client.js`) 原样直发; 自家代码的不可解析请求会让打包失败, 第三方不可解析请求则允许延后到 require 时明确失败.
4. **懒加载 chunk**——通过注入 Client factory 的 loader 的 `.async` 方法以字面量 `./client.<name>.js` 路径请求的每个页面 chunk,除非被 `./client` 导出点名,否则移出基础镜像。扫描器支持生产构建中改名后的 loader 变量,以及单引号、双引号或反引号。每个 chunk 成为基础镜像旁的一个独立确定性归档,命名为 `lazy/<归档的 sha256>.tar.gz`;manifest 为 worker 的懒加载文件系统登记其路径、大小和归档。请求包未发布的 chunk 会让打包失败。
4. **懒加载 chunk**——页面资产以字面量 `require.async("./client.<name>.js")`(动态 Client 构建产出的唯一形式)请求的每个页面 chunk,除非被 `./client` 导出点名,否则移出基础镜像。每个 chunk 成为基础镜像旁的一个独立确定性归档,命名为 `lazy/<归档的 sha256>.tar.gz`;manifest 为 worker 的懒加载文件系统登记其路径、大小和归档。请求包未发布的 chunk 会让打包失败。
`repository.ts` 拥有仓库形态输入(`vendor/`、`packages/`、`native/system/packages/` 与 `apps/` 的 workspace 扫描;经 `dsh-official-bundles` 的随附模板、optional bundle 列表和 app-boot 的 bundle patch 读取器得出 profile roster;以 CLI manifest 作为安装清单;home patch 在模板各 bundle 层合成出的行配置之上设置运行时 `PLATFORM_ENTRY_CONFIG` 字段);`pack.ts` 一概不拥有,同一库换参即可打另一棵树。Native 扫描使 Landlock 入口包成为普通发布视图依赖,其可执行文件仍由 Worker 平台实现。CLI 为 `dsh-pack-vfs-image --out <file> [--profile web]`;它把懒加载归档写在基础镜像旁,`apps/web` 的 `build:preview` 在预览壳构建后运行它。
@@ -580,10 +580,10 @@ function materialize(
}
/**
* Dynamic Client factories request package-local chunks through `.async` with
* a literal path. Minification can rename `require` and change the quote style.
* Request form the dynamic Client build emits for a package-local chunk: a
* literal `require.async("./client.<name>.js")`, served by the Host on demand.
*/
const CHUNK_REQUEST = /[A-Za-z_$][\w$]*\.async\(\s*(["'`])(\.\/client\.[A-Za-z0-9][A-Za-z0-9._-]*\.js)\1\s*\)/g
const CHUNK_REQUEST = /\brequire\.async\(\s*(["'])(\.\/client\.[A-Za-z0-9][A-Za-z0-9._-]*\.js)\1\s*\)/g
/**
* Image paths every materialized package's `./client` export names, under any
@@ -609,7 +609,7 @@ function clientEntriesOf(files: ImageFiles, packages: Iterable<string>): Set<str
}
/**
* Page chunks matched by {@link CHUNK_REQUEST}, in path order.
* Page chunks requested only through `require.async`, in path order.
* @param files - Image entries after the sweep.
* @param eager - Eager Client entry paths, never lazy.
* @returns Image paths to move into lazy archives.
@@ -664,7 +664,7 @@ function compressImage(archive: Uint8Array): Uint8Array {
* The manifest's claim is all-or-nothing: it names the one contract every packed body
* was emitted against. A module the transform cannot express therefore fails the pack
* rather than downgrading the image, because a mostly-transformed image boots into
* errors far from their cause. Page chunks matched by {@link CHUNK_REQUEST} leave
* errors far from their cause. Page chunks requested through `require.async` leave
* the base image for deterministic archives named by their content digest, which the manifest registers.
* @param options - Roster, installation, package index, and paths.
* @returns The compressed image, its lazy archives, and what went into them.
@@ -86,28 +86,6 @@ it('refuses a chunk request whose file the package does not publish', (test) =>
expect(() => packVfsImage(options)).toThrow(/client\.browser\.js requests chunk "\.\/client\.missing\.js"/)
})
it.for(['published', 'missing'] as const)('handles %s chunks with renamed loaders and backtick paths', (publication, test) => {
const options = fixture(test, {
...CHUNKS,
'client.browser.js': 'module.exports=e=>({load:()=>e.async(`./client.pdf.js`)})',
'client.pdf.js': 'module.exports=t=>({worker:()=>t.async(`./client.pdf-worker.js`)})',
})
if (publication === 'missing') {
rmSync(join(options.resolveFrom, 'lib/client.pdf-worker.js'))
expect(() => packVfsImage(options)).toThrow(/client\.pdf\.js requests chunk "\.\/client\.pdf-worker\.js"/)
} else {
const result = packVfsImage(options)
expect(Object.keys(result.files)).toContain(`${PREFIX}/client.browser.js`)
expect(Object.keys(result.files)).toContain(`${PREFIX}/client.orphan.js`)
expect(Object.keys(result.files)).not.toContain(`${PREFIX}/client.pdf.js`)
expect(Object.keys(result.files)).not.toContain(`${PREFIX}/client.pdf-worker.js`)
expect(result.lazyArchives.map(archive => archive.files)).toEqual([
[`${PREFIX}/client.pdf-worker.js`],
[`${PREFIX}/client.pdf.js`],
])
}
})
it('keeps a ./client export target in the base image even when a chunk requests it', (test) => {
const result = packVfsImage(fixture(test, {
'client.browser.js': 'module.exports = { load: () => require.async("./client.pdf.js") }',
@@ -38,7 +38,7 @@ export interface ImageManifest {
readonly visitedEntries: number
/** Visited entries the transform rewrote. */
readonly rewrittenEntries: number
/** Lazily requested page chunks, one archive each. */
/** Page chunks requested only through `require.async`, one archive each. */
readonly lazy: readonly ImageLazyArchive[]
}
+4 -24
View File
@@ -259,30 +259,10 @@ describe('client bundle package asset imports', () => {
write: false, clean: false, exports: false, report: false, logLevel: 'silent',
})
const output = builds.flatMap(bundle => bundle.chunks).find(chunk => chunk.type === 'chunk' && chunk.fileName === 'client.js')
if (output?.type !== 'chunk') throw new Error('asset fixture client output missing')
type AssetRequire = ((specifier: string) => never) & { asset(reference: string): string }
const requests: string[] = []
const require: AssetRequire = Object.assign((specifier: string): never => {
throw new Error(`Unexpected synchronous module request: ${specifier}`)
}, {
asset(reference: string): string {
requests.push(reference)
return `resolved:${reference}`
},
})
const registrations: { id: string; factory(require: AssetRequire): { url: string; text: string } }[] = []
runInNewContext(output.code, { window: { __ModuleLoader__: { load(value: typeof registrations[number]) {
registrations.push(value)
} } } })
expect(registrations).toHaveLength(1)
const registration = registrations[0]
if (registration === undefined) throw new Error('asset fixture factory registration missing')
expect(registration.id).toBe(PACKAGE_NAME)
expect(requests).toEqual([])
const exports = registration.factory(require)
expect(exports.url).toBe(`resolved:${WHALE_URL}`)
expect(exports.text).toBe('__dshPackageAsset(')
expect(requests).toEqual([WHALE_URL])
const code = output?.type === 'chunk' ? output.code : ''
expect(code).toContain(`require.asset(${JSON.stringify(WHALE_URL)})`)
expect(code).not.toContain('__dshPackageAsset_')
expect(code).toContain('"__dshPackageAsset("')
} finally {
for (const bundle of builds) await bundle[Symbol.asyncDispose]()
}
+3 -199
View File
@@ -3,11 +3,9 @@
* chaining, and physical watch dependencies hidden behind virtual CSS Modules.
*/
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { SourceMap } from 'node:module'
import { tmpdir } from 'node:os'
import { dirname, join, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
import { runInNewContext } from 'node:vm'
import { build, type Rolldown, type TsdownBundle, type UserConfig } from 'tsdown'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import { clientBundle, requestedExternals, staticLinked } from '../packages/client/tsdown.client.ts'
@@ -122,24 +120,6 @@ function dynamicImportFixture(prefix: string): string {
return root
}
interface ClientFactoryRegistration<Value, Require> {
id: string
chunk?: string
factory(require: Require): Value
}
function registerClientFactory<Value, Require>(code: string): ClientFactoryRegistration<Value, Require> {
const registrations: ClientFactoryRegistration<Value, Require>[] = []
runInNewContext(code, { window: { __ModuleLoader__: { load(value: ClientFactoryRegistration<Value, Require>) {
registrations.push(value)
} } } })
expect(registrations).toHaveLength(1)
const registration = registrations[0]
if (registration === undefined) throw new Error('client factory registration missing')
expect(registration.id).toBe(REQUESTING_PACKAGE)
return registration
}
describe('client bundle dynamic imports', () => {
it('keeps a dual Client browser chunk disjoint from its Node output', async () => {
const root = dynamicImportFixture('dsh-dual-client-chunk-')
@@ -175,191 +155,15 @@ describe('client bundle dynamic imports', () => {
write: false, clean: false, exports: false, report: false, logLevel: 'silent',
})
const chunks = builds.flatMap(bundle => bundle.chunks).filter(chunk => chunk.type === 'chunk')
const entry = chunks.find(chunk => chunk.fileName === 'client.js')
const terminal = chunks.find(chunk => chunk.fileName === 'client.terminal.js')
if (entry === undefined || terminal === undefined) throw new Error('dynamic import fixture outputs missing')
type Terminal = { marker: string }
type Require = ((specifier: string) => never) & { async(specifier: string): Promise<Terminal> }
const requests: string[] = []
const require: Require = Object.assign((specifier: string): never => {
throw new Error(`Unexpected synchronous module request: ${specifier}`)
}, {
async(specifier: string): Promise<Terminal> {
requests.push(specifier)
expect(specifier).toBe('./client.terminal.js')
const registration = registerClientFactory<Terminal, Require>(terminal.code)
expect(registration.chunk).toBe('client.terminal.js')
return Promise.resolve(registration.factory(require))
},
})
const registration = registerClientFactory<{ load(): Promise<Terminal> }, Require>(entry.code)
expect(registration.chunk).toBeUndefined()
const exports = registration.factory(require)
expect(requests).toEqual([])
await expect(exports.load()).resolves.toEqual({ marker: 'terminal' })
expect(requests).toEqual(['./client.terminal.js'])
const output = chunks.find(chunk => chunk.fileName === 'client.js')?.code
expect(output).toContain('require.async("./client.terminal.js")')
expect(output).not.toContain('Promise.resolve().then(() => require("./client.terminal.js"))')
} finally {
for (const bundle of builds) await bundle[Symbol.asyncDispose]()
}
})
})
interface CompressionFixtureDetails {
describeScore(value: number): string
}
interface CompressionFixtureEntry {
calculateScore(values: readonly number[]): number
activate(): number
incrementScore(value: number): number
decrementScore(value: number): number
FixtureAccumulator: { new (seed: number): { total(values: readonly number[]): number }; readonly name: string }
loadDetails(): Promise<CompressionFixtureDetails>
}
type CompressionFixtureRequire = ((specifier: string) => never) & {
async(specifier: string): Promise<CompressionFixtureDetails>
}
describe('production client bundle compression', () => {
it('keeps executable exports, lazy chunks, names, notices and maps while compressing production only', async () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-client-compression-'))
onTestFinished(() => { rmSync(root, { recursive: true, force: true }) })
const directory = join(root, 'lib/types/client')
mkdirSync(directory, { recursive: true })
writeFileSync(join(root, 'package.json'), JSON.stringify({ name: REQUESTING_PACKAGE, type: 'module' }))
// Every long local contributes to a runtime result; comments and dead branches cannot earn the byte reduction.
const terms = Array.from({ length: 24 }, (_, index) => `intermediateContributionForInputPosition${index}`)
const entrySource = [
'/*! @license Fixture arithmetic license */',
'export function calculateScore(values) {',
...terms.map((name, index) => ` const ${name} = values[${index}] * ${index + 1} + ${index + 2};`),
` return ${terms.join(' + ')};`,
'}',
'export class FixtureAccumulator {',
' constructor(seed) { this.seed = seed; }',
' total(values) { return this.seed + calculateScore(values); }',
'}',
'export const activate = () => 7;',
'export { collisionLabel as incrementScore } from "./increment.js";',
'export { collisionLabel as decrementScore } from "./decrement.js";',
'export function loadDetails() { return import("./details.js"); }',
'',
].join('\n')
const detailSource = [
'/*! @license Fixture detail license */',
'export function describeScore(value) {',
' const descriptiveScorePrefix = "score";',
' return descriptiveScorePrefix + ":" + value;',
'}',
'',
].join('\n')
writeFileSync(join(directory, 'index.js'), entrySource)
writeFileSync(join(directory, 'details.js'), detailSource)
writeFileSync(join(directory, 'increment.js'), 'export function collisionLabel(value) { return value + 1; }\n')
writeFileSync(join(directory, 'decrement.js'), 'export function collisionLabel(value) { return value - 1; }\n')
const namesByMode = new Map<string, readonly string[]>()
for (const mode of ['production', 'development'] as const) {
const previousNodeEnv = process.env.NODE_ENV
const noticeBody = '// Copyright Fixture Authors\n// Permission is granted to use this fixture.'
let config: UserConfig | undefined
try {
process.env.NODE_ENV = mode
config = clientBundle(root, ['lib/types/index.js'], {
clientBanner: fileName => `//! Fixture banner for ${fileName}\n${noticeBody}`,
})({ env: { DSH_BUILD_FACE: 'client' } }).find(candidate => candidate.platform === 'browser')
} finally {
if (previousNodeEnv === undefined) delete process.env.NODE_ENV
else process.env.NODE_ENV = previousNodeEnv
}
if (config === undefined) throw new Error('client config missing')
const builds = await build({
...config, cwd: root, config: false, tsconfig: false,
write: false, clean: false, exports: false, report: false, logLevel: 'silent',
})
try {
const chunks = builds.flatMap(bundle => bundle.chunks).filter(chunk => chunk.type === 'chunk')
expect(chunks.map(chunk => chunk.fileName).sort()).toEqual(['client.details.js', 'client.js'])
const entry = chunks.find(chunk => chunk.fileName === 'client.js')
const details = chunks.find(chunk => chunk.fileName === 'client.details.js')
if (entry === undefined || details === undefined) throw new Error('client fixture outputs missing')
const requests: string[] = []
const require: CompressionFixtureRequire = Object.assign((specifier: string): never => {
throw new Error(`Unexpected synchronous module request: ${specifier}`)
}, {
async(specifier: string): Promise<CompressionFixtureDetails> {
requests.push(specifier)
expect(specifier).toBe('./client.details.js')
const registration = registerClientFactory<CompressionFixtureDetails, CompressionFixtureRequire>(details.code)
expect(registration.chunk).toBe('client.details.js')
return Promise.resolve(registration.factory(require))
},
})
const registration = registerClientFactory<CompressionFixtureEntry, CompressionFixtureRequire>(entry.code)
expect(registration.chunk).toBeUndefined()
const exports = registration.factory(require)
const values = Array.from({ length: terms.length }, (_, index) => index * 3 - 7)
const expected = values.reduce((sum, value, index) => sum + value * (index + 1) + index + 2, 0)
expect(exports.calculateScore(values)).toBe(expected)
expect(new exports.FixtureAccumulator(9).total(values)).toBe(expected + 9)
expect(exports.incrementScore(expected)).toBe(expected + 1)
expect(exports.decrementScore(expected)).toBe(expected - 1)
expect(exports.calculateScore.name).toBe('calculateScore')
expect(exports.activate()).toBe(7)
expect(exports.activate.name).toBe('activate')
expect(exports.FixtureAccumulator.name).toBe('FixtureAccumulator')
namesByMode.set(mode, [exports.incrementScore.name, exports.decrementScore.name])
expect(requests).toEqual([])
const loaded = await exports.loadDetails()
expect(requests).toEqual(['./client.details.js'])
expect(loaded.describeScore(expected)).toBe(`score:${expected}`)
expect(loaded.describeScore.name).toBe('describeScore')
for (const [chunk, source, notice, name] of [
[entry, entrySource, 'Fixture arithmetic license', 'calculateScore'],
[details, detailSource, 'Fixture detail license', 'describeScore'],
] as const) {
expect(chunk.code).toContain(`@license ${notice}`)
expect(chunk.code).toContain(`//! Fixture banner for ${chunk.fileName}\n${noticeBody}`)
expect(chunk.map?.sourcesContent).toContain(source)
expect(chunk.map?.mappings.length).toBeGreaterThan(0)
expect(chunk.code.trimEnd().endsWith(`//# sourceMappingURL=${chunk.fileName}.map`)).toBe(true)
if (chunk.map === null) throw new Error('client fixture source map missing')
const map = new SourceMap({
...chunk.map, file: chunk.fileName, sourceRoot: '',
sourcesContent: chunk.map.sourcesContent.map(content => content ?? ''),
})
const marker = `function ${name}`
expect(chunk.code).toContain(marker)
// Rolldown maps a function declaration to the start of its export statement.
const generatedOffset = chunk.code.indexOf(marker)
const originalOffset = source.indexOf(`export ${marker}`)
const generatedLines = chunk.code.slice(0, generatedOffset).split('\n')
const originalLines = source.slice(0, originalOffset).split('\n')
expect(map.findEntry(generatedLines.length - 1, generatedLines.at(-1)!.length)).toMatchObject({
originalLine: originalLines.length - 1,
originalColumn: originalLines.at(-1)!.length,
})
}
if (mode === 'production') {
// These ceilings include the executable factory, loader call, legal notices and source-map reference.
expect(Buffer.byteLength(entry.code)).toBeLessThanOrEqual(1600)
expect(Buffer.byteLength(details.code)).toBeLessThanOrEqual(600)
} else {
expect(entry.code).toContain('intermediateContributionForInputPosition0')
}
} finally {
for (const bundle of builds) await bundle[Symbol.asyncDispose]()
}
}
// Minification preserves the bundler's names, including any static-module name collision suffixes.
expect(namesByMode.get('production')).toEqual(namesByMode.get('development'))
})
})
function clientSourceMapPath(packagePath: string): string {
return fileURLToPath(new URL(`../packages/${packagePath}/lib/client.js.map`, import.meta.url))
}