Repository Wiki
deepseek-ai/deepseek-harness

文件差异与文档预览

本页说明 web-client 中与文档预览页面及构建产物相关的实现:Vite 如何生成 preview.html、注入 worker bootstrap,并通过产品包隔离机制验证页面可达的脚本、样式、资源和 worker 输入。页面交互本身的 React 组件实现未在本页已读取的源码范围内发现,因此不对未验证的 UI 行为作推断。

Purpose and Scope

本页聚焦构建阶段的“文档预览”交付链路,尤其是 apps/web/vite.config.ts 中的 emitPreviewPage()、rejectStandaloneServe()、文档渲染相关 vendor chunk 配置,以及 apps/web/product-isolation.ts 与 WebProductBundleIsolation 对最终 index.html 可达依赖的追踪和校验。

页面内具体的 Markdown 编辑器、差异渲染 React 组件、路由和后端文档 API 不在当前源码证据范围内;这些应由对应的 UI、路由或服务端目录页面说明。部署命令的完整包装逻辑同样不在本页展开,只记录 Vite 配置明确表达的运行约束。

Overview

该能力不是单纯复制 index.html。构建时首先由 Vite 产出普通入口和名为 bootstrap 的 worker 入口;随后插件在 bundle 阶段定位 worker 文件,并在输出目录中读取已写入的 index.html,将 worker bootstrap 的 <script type="module"> 插入到原页面入口脚本之前,生成并列的 preview.html。两个页面共享构建 chunk,预览页面的差异仅是额外的 worker bootstrap 标签。

同时,产品包隔离插件会在 CSS 转换、资源 URL 解析、普通 chunk、worker 子构建等阶段记录输入关系。最终校验从 index.html 开始遍历静态导入、动态导入、CSS、资源和 worker,并将每个实际输入交给 BundleInputIsolation。这样做的设计意图是:预览页面可以复用已构建的产品页面,但不能悄悄携带未允许的实验性包或缺少来源证明的输出。

Architecture

Loading diagram...

Sources: vite.config.ts, product-isolation.ts, web-product-bundle-isolation.ts

vite.config.ts 负责把多个插件组合到同一构建中;product-isolation.ts 只负责把 Vite 生命周期适配为 WebProductBundleIsolation 的记录与验证调用;后者保存 chunk、CSS、资源引用和 worker 输入,并把实际输入交给底层隔离器。preview.html 并不重新生成一套页面资源,而是复用 index.html 的内容和同一个输出目录。

预览页面的生成机制

构建生命周期状态

emitPreviewPage() 维护四个局部状态:bootstrapFile 保存入口 chunk 文件名,write 保存 config.build.write,written 标记 Vite 是否真正写出了 bundle,outputDirectory 保存解析后的输出目录。configResolved 读取写入模式和目录;buildStart 在每次构建或 watch 重建时清空入口名及写入标志,避免上一次构建的文件名泄漏到下一次构建。

在 generateBundle 阶段,插件只接受同时满足 item.type === 'chunk'、item.isEntry 和 item.name === 'bootstrap' 的输出。如果没有找到该入口,会立即抛出错误,而不是生成一个缺少 worker bootstrap 的“看似成功”的预览页。这是一个重要的 fail-fast 约束。

从 index.html 派生 preview.html

Vite 完成写盘后,closeBundle 仅在写入模式、确实写过 bundle 且找到了 bootstrap 文件时继续。它读取输出目录下的 index.html,查找第一个 module script 标签,将下面形式的脚本插到该位置之前:

typescript
1const page = await readFile(resolve(outputDirectory, 'index.html'), 'utf8') 2const anchor = page.indexOf('<script type="module"') 3if (anchor === -1) throw new Error('vite: built index.html lost its module entry tag') 4const tag = `<script type="module" crossorigin src="./${bootstrapFile}"></script>` 5await writeFile(resolve(outputDirectory, 'preview.html'), `${page.slice(0, anchor)}${tag}${page.slice(anchor)}`)

Source: vite.config.ts

这里没有重新解析 HTML,也没有改变原入口标签的属性;实现只依赖一个明确的 <script type="module"> 锚点,并使用相对路径 ./${bootstrapFile},因此与配置中的 base: './' 一致,预览页面可以在输出目录的不同挂载路径下解析资源。

standalone serve 约束

同一配置显式拒绝裸 Vite serve:rejectStandaloneServe() 在 env.command === 'serve' 时抛出错误。源码说明原因是裸 Vite 无法注入 window.__DSH_BOOT__;因此预览相关页面必须经过项目自己的启动包装流程,而不是把 apps/web 当作独立 Vite 应用直接启动。

typescript
1function rejectStandaloneServe(): Plugin { 2 return { 3 name: 'dsh-reject-standalone-web-serve', 4 config(_config, env) { 5 if (env.command === 'serve') throw new Error(STANDALONE_ERROR) 6 }, 7 } 8}

Source: vite.config.ts

文档渲染依赖与输出组织

配置将 KaTeX、Shiki 以及 micromark/mdast 解析链列入 VENDOR_PACKAGES。源码注释明确说明这些是数学、语法高亮和 Markdown 解析的重量级依赖;React 侧的增量渲染代码仍留在 workspace 代码对应的 index chunk 中。配置还要求 vendor 成员保持 React-free,以免 Rollup 把共享 React 拖入 vendor chunk。

这一划分服务于预览场景的缓存稳定性:编辑 shell 代码时主要重新哈希 index,而 Markdown/math/highlight 依赖只有在依赖变更时才影响 vendor chunk。该结论仅适用于源码中列出的包和注释表达的构建策略,不代表所有前端资源都必然进入 vendor。

typescript
1const VENDOR_PACKAGES: ReadonlySet<string> = new Set([ 2 'katex', 3 'shiki', 4 'mdast-util-from-markdown', 5 'mdast-util-gfm', 6 'mdast-util-math', 7 'micromark-core-commonmark', 8 'micromark-extension-gfm', 9 'micromark-extension-math', 10 'micromark-factory-space', 11 'micromark-util-character', 12 'micromark-util-classify-character', 13 'micromark-util-sanitize-uri', 14 'micromark-util-symbol', 15 'micromark-util-types', 16])

Source: vite.config.ts

构建隔离适配层

productWebBundleIsolation(repository, webRoot) 创建一个 WebProductBundleIsolation 实例,并返回两个 Vite build 插件。第一个插件在 generateBundle 的 pre 阶段捕获 chunk;第二个插件在 post 阶段安装资源 URL、worker、CSS 输入记录,并在最终 bundle 阶段执行验证。

适配层会保留已有的 Vite worker 插件,在其后追加 dsh-worker-build-inputs。这意味着 worker 子构建不会被单独忽略,而是通过 inputs.workerBundle(bundle, this.getWatchFiles()) 记录其 watched files 和模块输入。

typescript
1export function productWebBundleIsolation(repository: string, webRoot: string): Plugin[] { 2 const inputs = new WebProductBundleIsolation(repository, webRoot) 3 let dependencyAnalysis = false 4 return [{ 5 name: 'dsh-product-web-chunk-inputs', 6 apply: 'build', 7 generateBundle: { 8 order: 'pre', 9 handler(_options, bundle) { 10 if (!dependencyAnalysis) inputs.captureChunks(bundle) 11 }, 12 }, 13 }, { 14 name: 'dsh-product-web-bundle-isolation', 15 apply: 'build', 16 enforce: 'post', 17 configResolved(config) { 18 dependencyAnalysis = config.plugins.some(plugin => plugin.name === DEPENDENCY_ANALYSIS_PLUGIN) 19 }, 20 buildStart() { inputs.reset() }, 21 generateBundle: { 22 order: 'post', 23 handler(_options, bundle) { 24 if (!dependencyAnalysis) inputs.verify(bundle, id => this.getModuleInfo(id)) 25 }, 26 }, 27 }] 28}

Source: product-isolation.ts Sources: product-isolation.ts

Core Flow

Loading diagram...

Sources: vite.config.ts, product-isolation.ts, web-product-bundle-isolation.ts

实际顺序的关键点是:隔离验证发生在最终 bundle 生命周期中,而 preview.html 的派生写入发生在 closeBundle。因此预览页只有在 bundle 产出并通过相关构建钩子后才会被写入;缺失 bootstrap、缺失 module entry 或隔离校验失败都会阻止正常生成。

依赖图遍历与验证细节

WebProductBundleIsolation 使用四类状态保存构建期间的证据:cssInputs 记录样式转换实际要求 Rollup watch 的文件,chunks 保存 Vite 输出 chunk 的导入和资源元数据,references 保存资源 URL 边,workerInputs 保存 worker 输出到其完整 watched input 集合的映射。reset() 会清空这些可重建的输出图和输入记录,适合 watch 重建;它不会假设上一次 bundle 的结构仍然有效。

typescript
1export class WebProductBundleIsolation { 2 private readonly cssInputs = new Map<string, Set<string>>() 3 private readonly chunks = new Map<string, WebOutputChunk>() 4 private readonly references: AssetReference[] = [] 5 private readonly workerInputs = new Map<string, Set<string>>() 6 private readonly inputs: BundleInputIsolation 7 private readonly webRoot: string 8 9 constructor(repository: string, webRoot: string) { 10 this.webRoot = webRoot 11 this.inputs = new BundleInputIsolation(repository, 'Web product isolation') 12 } 13 14 reset(): void { 15 this.chunks.clear() 16 this.references.length = 0 17 this.workerInputs.clear() 18 this.inputs.reset() 19 } 20}

Source: web-product-bundle-isolation.ts

验证从 index.html 入队开始。首先确认该 HTML 确实来自 web root 下的原始 index.html;然后建立 preliminary filename、资源原名和实际输出名之间的别名。遍历输出时,chunk 会继续扩展其静态导入、动态导入、隐式前置加载、引用文件、导入 CSS 和导入 asset;CSS 会回溯到拥有它的模块;普通 asset 则要求存在 originalFileNames。资源引用若是 public 文件,会解析到 webRoot/public;普通 asset 引用会再次加入遍历队列。

对每个模块,校验器会:

  1. 调用 assertInput 检查模块输入所有权;
  2. 拒绝没有 Rollup module record 的模块;
  3. 拒绝 external 模块,因为它没有 bundle 输入证明;
  4. 对 CSS、worker、静态导入和动态导入分别检查对应记录;
  5. 最终要求至少访问到一个模块,否则认为 index.html 没有可验证的可达模块。

这种实现把“页面能加载”与“页面所有输入可追溯”绑定在一起:仅有文件存在并不足够,输出图中每个可达节点都必须有来源证据。

配置与运行约束

配置或常量类型默认值/条件作用
basestring'./'使 index.html 和派生的 preview.html 使用相对资源路径。
targetstring'es2022'支持 worker bootstrap 使用的 top-level await。
sourcemapbooleantrue为构建产物生成 source map。
DSH_CLIENT_TITLE环境变量'DSH Local Build'经 HTML 转义后替换初始页面标题。
config.build.writeboolean由 Vite 决定为 false 时不写 preview.html,并允许依赖分析插件只分析不发布。
bootstrap entryVite entry必须存在emitPreviewPage() 必须找到名为 bootstrap 的 entry chunk,否则构建失败。

配置中 clientDocumentTitle() 使用 escapeHtmlText() 对环境变量中的 &、<、> 做转义后再写入 <title>,这是避免构建时文本直接进入 HTML 的必要边界处理。源码没有为标题设置长度限制或其他字符策略。

typescript
1function clientDocumentTitle(): Plugin { 2 const title = escapeHtmlText(process.env.DSH_CLIENT_TITLE ?? DEFAULT_CLIENT_TITLE) 3 return { 4 name: 'dsh-client-document-title', 5 transformIndexHtml(html) { 6 return html.replace('<title>DSH Local Build</title>', `<title>${title}</title>`) 7 }, 8 } 9}

Source: vite.config.ts

失败模式、边界与并发性

已验证的失败模式

  • 直接执行裸 Vite serve:rejectStandaloneServe() 抛出 STANDALONE_ERROR,防止缺少 boot manifest 的 shell 被暴露。
  • 缺少 bootstrap entry:generateBundle() 找不到名为 bootstrap 的入口时抛错。
  • index.html 缺少 module entry:closeBundle() 找不到插入锚点时抛错。
  • index.html 不是原始输入:隔离校验要求输出 asset 的 originalFileNames 包含 web root 下的 index.html。
  • 模块无 Rollup 记录或为 external:校验器分别报告缺少 module record 或缺少 bundled input proof。
  • CSS 未记录 transform 输入:CSS 文件进入依赖图但没有 cssInputs 记录时失败。
  • worker 没有输入记录:worker 输出未出现在 workerInputs 时失败。
  • asset 没有原始文件名:非 CSS、非模块资源缺少 originalFileNames 时失败。
  • 没有可达模块:遍历结束后 visitedModules.size === 0 时失败。

这些错误均在源码中通过同步或异步 throw new Error(...) 表达;当前已读取的实现没有重试、降级生成或吞掉错误的逻辑。因此构建流水线应把它们视为发布前的硬失败,而不是运行时告警。

watch 重建与状态隔离

buildStart() 会重置预览插件的入口状态,产品隔离适配器也在 buildStart() 调用 inputs.reset()。这两个动作降低了 watch 模式下使用旧 bundle 图和旧 bootstrap 文件名的风险。源码使用普通局部变量、Map 和 Set,没有看到异步并发锁;Vite 生命周期按钩子顺序调用时,状态属于一次构建过程。若外部同时启动多个独立 Vite 实例,它们是否共享输出目录或发生写入竞争,当前源码没有提供协调机制。

API 与扩展点

emitPreviewPage(): Plugin

返回 Vite 插件。它读取构建配置,捕获 bootstrap entry,并在满足写入条件时生成 preview.html。该函数没有参数,返回值是 Vite Plugin。它的扩展边界是入口命名、HTML 插入锚点和输出文件命名;变更这些约定需要同时更新实际 worker entry 或页面模板。

productWebBundleIsolation(repository: string, webRoot: string): Plugin[]

创建并返回两个只应用于 build 的 Vite 插件。repository 用于构造 WebProductBundleIsolation 的底层输入隔离器,webRoot 用于解析 index.html、public asset 和原始文件。返回数组包含一个 pre generateBundle 插件和一个 post generateBundle 插件。若修改 hook 顺序,必须保留“先捕获 chunk,再验证最终 bundle”的关系。

WebProductBundleIsolation.verify(bundle, moduleInfo)

从输出 index.html 遍历可达产物,并通过回调取得模块记录。它不返回业务结果;验证成功时自然结束,发现输入缺失、external 依赖、无记录模块或不可追溯资源时抛出错误。moduleInfo 必须能为遍历中的模块提供 importedIds、dynamicallyImportedIds、isExternal 和 isIncluded 等字段,否则无法完成验证。

性能与运维注意事项

隔离校验采用 visitedOutputs 和 visitedModules 去重,并将资源别名预先放入 Map;因此同一 chunk、模块或资源不会因多条引用边被重复递归。它会遍历 index.html 实际可达的依赖闭包,而不是扫描整个仓库,这既减少无关检查,也使产物可达性成为安全边界。

Markdown 解析相关的重量级依赖被集中到 vendor 集合,目的在源码注释中明确为改善缓存和 chunk 稳定性。sourcemap: true 有利于构建产物诊断,但会增加输出体积;本页读取的配置没有提供 source map 的环境开关。

测试与证据边界

在本页限定的 6 次源代码探索预算内,没有读取测试文件,也没有读取具体 UI 预览组件。因此无法从已验证证据声明测试覆盖范围、差异算法、Markdown 安全策略或最终用户可见状态。当前页面只记录构建插件和隔离器中明确存在的行为;实现细节不足之处应在对应页面补充,而不应根据配置名称推断。