diff --git a/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.i18n.yaml
new file mode 100644
index 0000000000..b2687ac5fd
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md
+2026-08-20-webworker-pack-lowering-and-preview.md: d4a3d0b2125421e761eb1616a7605b58d0d77da3
+2026-08-20-webworker-pack-lowering-and-preview.zh.md: 24ff21957c31783d1b375c6589bc6114b3be1972
diff --git a/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md b/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md
new file mode 100644
index 0000000000..d4a3d0b212
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md
@@ -0,0 +1,34 @@
+# Agent Note: pack-time lowering and the single-build preview
+
+Status: implemented
+
+English | [中文](2026-08-20-webworker-pack-lowering-and-preview.zh.md)
+
+## Problem
+
+The browser worker can neither compile modules at load nor be served by the product webserver: every module body must arrive runnable, and the page must be a static artifact. Both surfaces drifted early. The loader carried a fallback compiler, so a collector gap surfaced as a slow boot instead of a broken image — and `acorn` rode into `lib/worker.js` through the package barrel, a parser a runtime that only wraps pre-lowered bodies never needs. The preview was a second HTML template beside the served one, a page the served index could silently drift away from.
+
+## Decision
+
+**Lowering happens at pack time only.** `@deepseek-ai/dsh-experimental-webworker-packer` composes the profile, materializes the closure, and lowers every JavaScript body; `LOWERING_VERSION` and `WRAPPER_PARAMS` are the pack↔worker contract and live in `src/image-layout.ts` beside the rest of the image layout. The loader wraps bodies exactly as the image holds them: a body still carrying module syntax is a refusal naming the image, and `startWorkerHost` requires the manifest's `lowered` to equal this build's contract before it mounts a single module. `lowerModuleSource` is the transform's only face and the packer its only caller; inside the worker graph, imports name the module that owns the value — never the package barrel, which is the edge that smuggled the parser in.
+
+**The preview is the served page plus one tag.** One Vite build emits `dist/index.html` and `dist/preview.html` sharing every chunk; the only difference is a prepended bootstrap entry whose module connects the worker host. Startup then converges on one protocol: whichever side applies the injection table settles the `__DSH_BOOT_READY__` deferred — the served renderer resolves it in a tail script after the rendered rows, the worker bootstrap installs it before its first await and settles it after the last row — and the client entry awaits it before reading any injected state, so the chain from the stock entry onward is the served chain verbatim. The build uses a relative base so the output mounts under any static directory; the served form anchors deep SPA-fallback paths by rendering `` at serve time, keeping the on-disk pages byte-shared.
+
+Both packages live in `packages/experimental/` as `@deepseek-ai/dsh-experimental-*`, private and outside official releases. The boundary that carries product promises stays in the product packages: the injection table, `__DSH_TRANSPORT__`, and the `/plugins` bundle bytes are owned by `dsh-host-webserver`, `dsh-client-modules`, and `dsh-client-connection`.
+
+## Alternatives considered
+
+**A load-time transform as a safety net.** It turned a broken image into a timing regression nobody attributed, and made "which path lowered this body" unanswerable from outside.
+
+**Contract constants inside the transform, trusting tree shaking.** The transform functions did shake out, but `acorn` declares no `sideEffects`, so the barrel edge alone carried the whole parser into the worker bundle.
+
+**A separate preview template.** The retired `preview.html` template duplicated the served document and drifted (language, title, entry wiring). Deriving the page from the built index at `closeBundle` removes the second document entirely.
+
+**Gating the stock entry on top-level await ordering instead of a deferred.** Sibling module scripts do not wait for one another's top-level awaits; the `??=`-installed deferred makes the handshake order-independent and lets a failed handshake reject into the boot page's failure rendering.
+
+## Consequences
+
+- `lib/worker.js` contains no parser (423.5 kB → 246.3 kB at the time of the cut, before the shell process layer landed).
+- `diff dist/index.html dist/preview.html` is exactly one script tag; `packages/experimental/webworker-packer/tests/image-loadable.spec.ts` pins both halves of the loader contract, and `apps/web/tests/preview-boot.e2e.ts` pins preview usability (boot to an interactive page) in the web browser lane, replacing the retired `apps/web/scripts/preview/` probe scripts.
+- The served `` anchor exists because relative asset URLs would resolve under the request directory on SPA-fallback paths; remove it only together with the relative build base.
+- The image ships as a deterministically gzip-compressed tar (`vfs-image.tar.gz`; MTIME 0, OS byte 0xff): static hosts do not compress binary content types (type allowlists, CDN size caps), so the compression rides the artifact, and the worker inflates the fetch body through the browser's native `DecompressionStream` while it downloads.
diff --git a/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md b/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md
new file mode 100644
index 0000000000..24ff21957c
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md
@@ -0,0 +1,34 @@
+# Agent Note:pack 期 lowering 与单构建 preview
+
+状态:已实施
+
+[English](2026-08-20-webworker-pack-lowering-and-preview.md) | 中文
+
+## 问题
+
+浏览器 worker 既不能在装载期编译模块,也不能由产品 webserver 提供页面:每个模块体必须以可直接运行的形态到达,页面必须是静态产物。两个面早期都发生过漂移。装载器曾携带一个兜底编译器,于是收集器的缺口表现为「启动变慢」而不是「镜像坏了」——而且 `acorn` 经包 barrel 混进了 `lib/worker.js`,一个只包装预 lowered 模块体的运行时根本不需要解析器。preview 曾是服务页面旁的第二份 HTML 模板,一个 served index 可以悄悄漂离的页面。
+
+## 决定
+
+**Lowering 只发生在 pack 期。** `@deepseek-ai/dsh-experimental-webworker-packer` 组合 profile、物化闭包、lower 每个 JavaScript 模块体;`LOWERING_VERSION` 与 `WRAPPER_PARAMS` 是 pack↔worker 的契约,与镜像布局的其余部分一起放在 `src/image-layout.ts`。装载器完全按镜像持有的形态包装模块体:仍带模块语法的模块体是一次点名镜像的拒绝,且 `startWorkerHost` 在挂载任何模块之前要求 manifest 的 `lowered` 等于本构建的契约。`lowerModuleSource` 是转换器唯一的面、packer 是它唯一的调用方;worker 图内部的 import 一律指向拥有该值的模块——绝不指向包 barrel,那正是把解析器偷运进来的那条边。
+
+**preview 就是服务页面加一个标签。** 一次 Vite 构建产出共享全部 chunk 的 `dist/index.html` 与 `dist/preview.html`;唯一差异是前插的一个引导入口,其模块负责连接 worker host。启动随之汇于一个协议:应用注入表的一方 settle `__DSH_BOOT_READY__` deferred——served 渲染器在渲染完的行之后用尾部脚本 resolve,worker 引导段在首个 await 之前安装、末行生效后 settle——client 入口在读取任何注入状态前 await 它,因此从标准入口起的链路逐字就是 served 链路。构建使用相对 base,产物可挂载于任意静态目录;served 形态在 serve 期渲染 `` 锚定深层 SPA fallback 路径,磁盘上的两个页面保持字节共享。
+
+两个包以 `@deepseek-ai/dsh-experimental-*` 名义放在 `packages/experimental/`,私有且在官方发布之外。承载产品承诺的边界仍在产品包里:注入表、`__DSH_TRANSPORT__` 与 `/plugins` bundle 字节由 `dsh-host-webserver`、`dsh-client-modules`、`dsh-client-connection` 拥有。
+
+## 曾考虑的替代方案
+
+**保留装载期转换器作安全网。** 它把坏镜像变成无人归因的耗时回归,并且让「这个模块体是谁 lower 的」从外部不可回答。
+
+**契约常量留在转换器里,信任 tree shaking。** 转换函数确实被摇掉了,但 `acorn` 未声明 `sideEffects`,仅 barrel 一条边就把整个解析器带进了 worker bundle。
+
+**独立的 preview 模板。** 已退役的 `preview.html` 模板复制了服务文档并发生漂移(语言、标题、入口接线)。在 `closeBundle` 从 built index 派生页面则彻底消灭了第二份文档。
+
+**用顶层 await 顺序而非 deferred 去闸标准入口。** 兄弟 module script 互不等待对方的顶层 await;`??=` 安装的 deferred 使握手与求值顺序无关,且失败的握手能 reject 进 boot 页的失败呈现。
+
+## 后果
+
+- `lib/worker.js` 不含解析器(当刀落时为 423.5 kB → 246.3 kB,早于 shell 进程层落地)。
+- `diff dist/index.html dist/preview.html` 恰为一个 script 标签;`packages/experimental/webworker-packer/tests/image-loadable.spec.ts` 钉住装载器契约的两半,`apps/web/tests/preview-boot.e2e.ts` 在 web 浏览器车道钉住 preview 可用性(boot 到可交互页面),替代已撤编的 `apps/web/scripts/preview/` 探针脚本。
+- served 的 `` 锚存在的原因是:相对资产 URL 在 SPA fallback 深路径下会解析进请求目录;只有与相对构建 base 一起才可移除它。
+- 镜像以确定性 gzip 压缩的 tar 交付(`vfs-image.tar.gz`;MTIME 0、OS 字节 0xff):静态托管不压缩二进制 content-type(类型白名单、CDN 尺寸帽),压缩必须随制品走;worker 用浏览器原生 `DecompressionStream` 在下载的同时解压 fetch body。
diff --git a/apps/web/package.json b/apps/web/package.json
index bef80ee0a9..23e15a5821 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -17,12 +17,16 @@
},
"files": [
"dist",
- "!dist/**/*.map"
+ "!dist/**/*.map",
+ "!dist/preview.html",
+ "!dist/preview"
],
"scripts": {
"build": "vite build",
"dev": "vite",
- "watch": "vite build --watch --no-emptyOutDir"
+ "watch": "vite build --watch --no-emptyOutDir",
+ "build:preview": "vite build && dsh-pack-vfs-image --out dist/preview/vfs-image.tar.gz",
+ "serve:preview": "http-server dist -a 0.0.0.0 -p 4173 -c-1"
},
"license": "MIT",
"devDependencies": {
@@ -33,16 +37,19 @@
"@deepseek-ai/dsh-client-web": "workspace:^",
"@deepseek-ai/dsh-cmdline": "workspace:^",
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
+ "@deepseek-ai/dsh-experimental-webworker-packer": "workspace:^",
+ "@deepseek-ai/dsh-experimental-webworker-runtime": "workspace:^",
"@types/node": "^22.0.0",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",
"@vitejs/plugin-react": "^4.0.0",
+ "http-server": "^14.1.1",
+ "fflate": "^0.8.2",
"playwright": "^1.49.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^6.0.3",
"vite": "^6.0.0",
- "vitest": "^4.1.8",
- "fflate": "^0.8.2"
+ "vitest": "^4.1.8"
}
}
diff --git a/apps/web/src/preview.ts b/apps/web/src/preview.ts
new file mode 100644
index 0000000000..586cbcab5d
--- /dev/null
+++ b/apps/web/src/preview.ts
@@ -0,0 +1,12 @@
+/**
+ * Worker-preview bootstrap: the one module preview.html adds ahead of the
+ * stock entry tag. Connecting the worker host installs the boot globals and
+ * settles `__DSH_BOOT_READY__`, where the stock entry's pre-boot await holds,
+ * so everything after this module is the served startup chain verbatim. A
+ * failed handshake rejects the deferred into the boot page's failure
+ * rendering; this module owns no page painting.
+ */
+import DshWorker from '@deepseek-ai/dsh-experimental-webworker-runtime/worker?worker'
+import { connectWorkerHost, IMAGE_FILE_NAME } from '@deepseek-ai/dsh-experimental-webworker-runtime/client'
+
+await connectWorkerHost(new DshWorker({ name: 'dsh-host' }), { image: `preview/${IMAGE_FILE_NAME}` })
diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts
new file mode 100644
index 0000000000..11f02fe2a0
--- /dev/null
+++ b/apps/web/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts
new file mode 100644
index 0000000000..b2d6add83a
--- /dev/null
+++ b/apps/web/tests/preview-boot.e2e.ts
@@ -0,0 +1,242 @@
+/**
+ * Preview acceptance: the browser-only worker deployment boots the real Cordis
+ * tree out of the packed VFS image and reaches an interactive page.
+ *
+ * `dist/preview.html` is the served page plus one bootstrap script tag, so this
+ * run exercises the shipped startup chain: the worker mounts the image,
+ * activates the tree, and answers the page's tunnel until the client settles.
+ * Two milestones prove that happened — the host's `tree active` boot line,
+ * whose lowering contract must be the one this checkout's packer emits, and the
+ * workspace hero, which paints only after the client tree comes up over the
+ * tunnel.
+ *
+ * The site is served the way a static host serves it: bytes from `dist/` with
+ * no rewrite rules, so a missing file is a 404 rather than the index page.
+ */
+import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { readFile } from 'node:fs/promises'
+import { createServer } from 'node:http'
+import type { IncomingMessage, ServerResponse } from 'node:http'
+import { tmpdir } from 'node:os'
+import { extname, join, normalize } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { chromium } from 'playwright'
+import type { Browser } from 'playwright'
+import { expect, it } from 'vitest'
+import {
+ composeProfile, configTrees, indexWorkspacePackages, packVfsImage, WRAPPER_CONTRACT,
+} from '@deepseek-ai/dsh-experimental-webworker-packer'
+import { IMAGE_FILE_NAME } from '@deepseek-ai/dsh-experimental-webworker-runtime'
+import { newEnglishPage, REPO_ROOT, saveFailureShot } from './support.ts'
+
+const DIST_ROOT = fileURLToPath(new URL('../dist', import.meta.url))
+
+/** Where the client looks for the image: the runtime's own name, beside the page. */
+const IMAGE_FILE = join(DIST_ROOT, 'preview', IMAGE_FILE_NAME)
+
+/** Profile the preview deployment composes; `build:preview` packs the same one. */
+const PROFILE = 'web'
+
+/** Pages the preview needs; the Vite build emits both. */
+const PAGES = ['index.html', 'preview.html']
+
+/**
+ * Content types the preview loads. Anything else is served as opaque bytes.
+ *
+ * The image goes out as `application/gzip` with no `content-encoding`: the
+ * worker inflates the gzip member itself, so a transport-decoded body would
+ * leave its `DecompressionStream('gzip')` with plain tar bytes to inflate.
+ */
+const MIME: Record = {
+ '.html': 'text/html; charset=utf-8',
+ '.js': 'text/javascript; charset=utf-8',
+ '.css': 'text/css; charset=utf-8',
+ '.json': 'application/json; charset=utf-8',
+ '.map': 'application/json; charset=utf-8',
+ '.svg': 'image/svg+xml',
+ '.gz': 'application/gzip',
+ '.webmanifest': 'application/manifest+json',
+ '.woff2': 'font/woff2',
+}
+
+/** Boot line the worker host writes once its tree finished activating. */
+const TREE_ACTIVE = 'webworker host: tree active'
+
+/** Image fetch, mount, and tree activation on a loaded machine. */
+const BOOT_TIMEOUT_MS = 240_000
+
+/** Client tree settle after the tunnel starts answering. */
+const HERO_TIMEOUT_MS = 240_000
+
+/** One served origin over `dist/`. */
+interface Site {
+ readonly origin: string
+ /** Release the port; call after the browser is gone. */
+ close(): Promise
+}
+
+/**
+ * Fail before the browser opens a page the build never produced.
+ * @throws When either preview page is missing from `dist/`.
+ */
+function requirePreviewPages(): void {
+ for (const page of PAGES) {
+ if (existsSync(join(DIST_ROOT, page))) continue
+ throw new Error(`preview boot needs apps/web/dist/${page} — run \`pnpm run build\` from the repository root`)
+ }
+}
+
+/**
+ * The image file to serve, packed here when `dist/` carries none: `pnpm run
+ * build` emits the pages but only `build:preview` packs, so this lane packs
+ * for itself rather than skipping the deployment it is here to accept. An
+ * image already in place is used as it stands — the worker refuses one lowered
+ * against another wrapper contract, and that refusal names the rebuild. A
+ * self-packed image lands in a temp directory, never in `dist/`: the
+ * client-artifact digest record treats `dist/` as build-owned, so a test write
+ * there fails the record check for every later consumer.
+ * @returns The file to answer `preview/` with, and its teardown.
+ * @throws When the closure leaves dependencies unresolved, which would pack an
+ * incomplete image the tree fails on later and further from the cause.
+ */
+function requireVfsImage(): { path: string; cleanup(): void } {
+ if (existsSync(IMAGE_FILE)) return { path: IMAGE_FILE, cleanup: () => {} }
+ const packed = packVfsImage({
+ config: composeProfile(REPO_ROOT, PROFILE),
+ profile: PROFILE,
+ workspaces: indexWorkspacePackages(REPO_ROOT),
+ resolveFrom: REPO_ROOT,
+ configTrees: configTrees(REPO_ROOT),
+ })
+ if (packed.missing.length > 0) {
+ throw new Error(`preview boot: ${String(packed.missing.length)} dependencies did not resolve: ${packed.missing.join(', ')}`)
+ }
+ const directory = mkdtempSync(join(tmpdir(), 'dsh-preview-boot-'))
+ const path = join(directory, IMAGE_FILE_NAME)
+ writeFileSync(path, packed.image)
+ return { path, cleanup: () => { rmSync(directory, { recursive: true, force: true }) } }
+}
+
+/**
+ * Answer one request with the file it names under `dist/`; the image path
+ * answers from wherever {@link requireVfsImage} put the file.
+ * @param request - Incoming request; only its path is read.
+ * @param response - Response to write the bytes or the 404 to.
+ * @param imagePath - File behind `preview/`.
+ */
+async function respond(request: IncomingMessage, response: ServerResponse, imagePath: string): Promise {
+ const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname
+ const relative = normalize(decodeURIComponent(path)).replace(/^\/+/, '')
+ try {
+ const body = await readFile(relative === `preview/${IMAGE_FILE_NAME}` ? imagePath : join(DIST_ROOT, relative))
+ response.writeHead(200, { 'content-type': MIME[extname(relative)] ?? 'application/octet-stream' })
+ response.end(body)
+ } catch {
+ // A miss is a miss: the deployment has no SPA fallback, and hiding one
+ // behind the index page would make a broken asset URL look like a boot
+ // failure.
+ response.writeHead(404)
+ response.end(`not found: ${relative}`)
+ }
+}
+
+/**
+ * Serve `dist/` over loopback with static-host semantics.
+ * @param imagePath - File behind `preview/`.
+ * @returns The origin to navigate, and its teardown.
+ */
+async function serveDist(imagePath: string): Promise {
+ const server = createServer((request, response) => { void respond(request, response, imagePath) })
+ await new Promise((listening) => { server.listen(0, '127.0.0.1', listening) })
+ const address = server.address()
+ if (address === null || typeof address === 'string') throw new Error('preview boot: the static server bound no port')
+ return {
+ origin: `http://127.0.0.1:${String(address.port)}`,
+ close: async () => {
+ server.closeAllConnections()
+ await new Promise((closed, reject) => {
+ server.close((error) => {
+ if (error === undefined) closed()
+ else reject(error)
+ })
+ })
+ },
+ }
+}
+
+/**
+ * Bound one boot milestone so a stall names the milestone instead of surfacing
+ * as the lane's generic test timeout.
+ * @param work - The milestone to wait for.
+ * @param ms - How long it may take.
+ * @param stalled - Error message when it does not arrive in time.
+ * @returns What `work` resolved to.
+ */
+async function within(work: Promise, ms: number, stalled: string): Promise {
+ let timer: NodeJS.Timeout | undefined
+ try {
+ return await Promise.race([
+ work,
+ new Promise((_, reject) => { timer = setTimeout(() => { reject(new Error(stalled)) }, ms) }),
+ ])
+ } finally {
+ clearTimeout(timer)
+ }
+}
+
+it('boots the packed worker deployment to an interactive page', async () => {
+ requirePreviewPages()
+ const image = requireVfsImage()
+ try {
+ const site = await serveDist(image.path)
+ try {
+ const browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] })
+ try {
+ await bootPreview(site.origin, browser)
+ } finally {
+ await browser.close()
+ }
+ } finally {
+ await site.close()
+ }
+ } finally {
+ image.cleanup()
+ }
+}, 600_000)
+
+/**
+ * Open the preview page and hold it to both boot milestones.
+ * @param origin - Origin serving `dist/`.
+ * @param browser - Browser to open the page in.
+ */
+async function bootPreview(origin: string, browser: Browser): Promise {
+ const page = await newEnglishPage(browser)
+ const pageErrors: Error[] = []
+ page.on('pageerror', (error) => { pageErrors.push(error) })
+ // Registered before navigation: the worker reports its tree long before the
+ // tunnel serves the client, so a listener added later would miss the line.
+ const treeActive = new Promise((reported) => {
+ page.on('console', (message) => {
+ const text = message.text()
+ if (text.includes(TREE_ACTIVE)) reported(text)
+ })
+ })
+ try {
+ await page.goto(`${origin}/preview.html`, { waitUntil: 'domcontentloaded' })
+ const bootLine = await within(treeActive, BOOT_TIMEOUT_MS, `preview boot: the worker never reported "${TREE_ACTIVE}"`)
+ // The activated tree ran bodies lowered against the contract this
+ // checkout's packer emits; a dist built before a contract change would
+ // report the older one.
+ expect(bootLine).toContain(`image lowering=${WRAPPER_CONTRACT}`)
+ // The hero's workspace picker is the client tree's first interactive
+ // surface, so it appears only once the startup chain completed over the
+ // tunnel.
+ await page.getByRole('textbox', { name: 'Choose workspace' }).waitFor({ timeout: HERO_TIMEOUT_MS })
+ expect(pageErrors.map(error => error.message)).toEqual([])
+ } catch (error) {
+ await saveFailureShot(page, 'preview-boot')
+ throw pageErrors.length === 0
+ ? error
+ : new AggregateError([error, ...pageErrors], 'preview boot failed, with uncaught page errors')
+ }
+}
diff --git a/apps/web/tests/pwa-manifest.e2e.ts b/apps/web/tests/pwa-manifest.e2e.ts
index fe97e42da9..08e210fa26 100644
--- a/apps/web/tests/pwa-manifest.e2e.ts
+++ b/apps/web/tests/pwa-manifest.e2e.ts
@@ -7,7 +7,7 @@ const DIST_ROOT = fileURLToPath(new URL('../dist', import.meta.url))
it('ships install metadata with the built web application', async () => {
const index = await readFile(join(DIST_ROOT, 'index.html'), 'utf8')
- expect(index).toContain('')
+ expect(index).toContain('')
const manifest: unknown = JSON.parse(await readFile(join(DIST_ROOT, 'manifest.webmanifest'), 'utf8'))
expect(manifest).toEqual({
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index 38a0438ef9..bbad4aadd9 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -49,6 +49,7 @@
"tests/workspace-management.e2e.ts",
"tests/replay-round-trip.e2e.ts",
"tests/hmr-live.e2e.ts",
+ "tests/preview-boot.e2e.ts",
"tests/seeded-history.e2e.ts",
"tests/cold-blank-session.e2e.ts",
"tests/stats-paged-history.e2e.ts",
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index cd27136cb7..dff22a99ec 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -1,3 +1,4 @@
+import { readFile, writeFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite'
import type { Plugin } from 'vite'
@@ -36,6 +37,35 @@ function rejectStandaloneServe(): Plugin {
}
}
+/**
+ * Emit preview.html beside index.html: the built index page with one module
+ * script — the worker bootstrap entry — spliced ahead of its entry tag. Both
+ * pages share every chunk; the extra tag is the only difference, so the
+ * static worker deployment ships the served page verbatim plus its
+ * bootstrap.
+ */
+function emitPreviewPage(): Plugin {
+ let bootstrapFile: string | undefined
+ return {
+ name: 'dsh-emit-preview-page',
+ generateBundle(_options, bundle) {
+ for (const item of Object.values(bundle)) {
+ if (item.type === 'chunk' && item.isEntry && item.name === 'bootstrap') bootstrapFile = item.fileName
+ }
+ if (bootstrapFile === undefined) throw new Error('vite: preview bootstrap entry missing from the bundle')
+ },
+ async closeBundle() {
+ // A build that failed before generateBundle has no page to splice.
+ if (bootstrapFile === undefined) return
+ const page = await readFile(src('./dist/index.html'), 'utf8')
+ const anchor = page.indexOf('`
+ await writeFile(src('./dist/preview.html'), `${page.slice(0, anchor)}${tag}${page.slice(anchor)}`)
+ },
+ }
+}
+
/**
* Vendor-chunk membership, by exact npm package name — the heavy render
* families (math, highlight, markdown) that change only on dependency bumps.
@@ -108,11 +138,30 @@ function npmPackageOf(id: string): string | undefined {
}
export default defineConfig({
- plugins: [rejectStandaloneServe(), clientDocumentTitle(), react()],
+ // Relative asset URLs: preview.html mounts the same output under any base
+ // directory, and the served index resolves identically from the site root.
+ base: './',
+ plugins: [rejectStandaloneServe(), clientDocumentTitle(), react(), emitPreviewPage()],
build: {
+ // The worker bootstrap holds its page at top-level await; Vite's default
+ // `modules` target (es2020-era) rejects that syntax.
+ target: 'es2022',
sourcemap: true,
rollupOptions: {
+ input: {
+ index: src('./index.html'),
+ // Standalone entry, not an index.html script tag: Vite folds every
+ // module tag of one page into a single synthetic entry, and only a
+ // separate input keeps the shared page chunks bootstrap-free.
+ bootstrap: src('./src/preview.ts'),
+ },
output: {
+ // The worker-preview surface groups under dist/preview/ (the page
+ // itself stays at dist/preview.html), so the published payload can
+ // exclude it as one directory.
+ entryFileNames(chunk): string {
+ return chunk.name === 'bootstrap' ? 'preview/[name]-[hash].js' : 'assets/[name]-[hash].js'
+ },
// Output layout: the two main chunks stay at assets/ root; lazy
// @shikijs/langs grammar chunks group under assets/langs/; fonts
// (all KaTeX faces referenced by vendor.css) group under
@@ -144,6 +193,10 @@ export default defineConfig({
},
},
},
+ worker: {
+ // The preview worker rides dist/preview/ with the rest of that surface.
+ rollupOptions: { output: { entryFileNames: 'preview/[name]-[hash].js' } },
+ },
resolve: {
// One instance per shared npm identity: a bare specifier otherwise resolves
// from the importer's directory, so a diverging range ships a second React
diff --git a/packages/host/frontend-static/src/index.ts b/packages/host/frontend-static/src/index.ts
index 1afd319906..1227299362 100644
--- a/packages/host/frontend-static/src/index.ts
+++ b/packages/host/frontend-static/src/index.ts
@@ -44,6 +44,10 @@ const MIME: Record = {
'.json': 'application/json',
'.map': 'application/json',
'.webmanifest': 'application/manifest+json',
+ // The packed VFS image. Served as its own bytes, never as a Content-Encoding:
+ // the worker inflates the body itself, and a transport-level encoding would
+ // leave it inflating an already-decoded archive.
+ '.gz': 'application/gzip',
}
const STATIC_MISS_CODES: ReadonlySet = new Set([
@@ -104,8 +108,14 @@ export async function serveStatic(
export function apply(ctx: Context, config: Config): void {
const distIndex = config.distIndex
const distRoot = dirname(distIndex)
- const renderIndex = async (): Promise =>
- ctx.webServer.renderIndex(await readFile(distIndex, 'utf8'))
+ // The dist is built with a relative base so the same files mount under any
+ // static directory; served pages also answer deep SPA-fallback paths, where
+ // relative asset URLs would resolve under the request directory, so the
+ // served form anchors them at the site root ahead of every URL-bearing tag.
+ const renderIndex = async (): Promise => {
+ const body = ctx.webServer.renderIndex(await readFile(distIndex, 'utf8'))
+ return body.replace(/]*)?>/i, open => `${open}`)
+ }
ctx.effect(() => ctx.webServer.registerFallback(async (req, res) => {
// Non-GET/HEAD without a matching named route is 405 (fallback-only
// semantics: named routes own their method handling).
diff --git a/packages/host/frontend-static/tests/frontend-static.spec.ts b/packages/host/frontend-static/tests/frontend-static.spec.ts
index fda9dcbc3e..93989857b9 100644
--- a/packages/host/frontend-static/tests/frontend-static.spec.ts
+++ b/packages/host/frontend-static/tests/frontend-static.spec.ts
@@ -80,7 +80,9 @@ async function request(port: number, path: string, init?: RequestInit): Promise<
return {
status: response.status,
type: response.headers.get('content-type'),
- body: (await response.text()).slice(0, 80),
+ // Window wide enough to keep index body markers visible behind the
+ // served prelude (base anchor + injection rows + boot-readiness tail).
+ body: (await response.text()).slice(0, 200),
}
}
diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts
index 314565fe54..336589bd2d 100644
--- a/scripts/check-workspace-constraints.ts
+++ b/scripts/check-workspace-constraints.ts
@@ -58,9 +58,10 @@ const releaseMemberDirectory = /^(?:packages\/(?!experimental\/)[^/]+\/[^/]+|app
const localArtifactDirs = new Set(['node_modules'])
const appPackageFiles: Readonly> = {
'@deepseek-ai/dsh': ['lib/*.js', 'config'],
- // The Web build emits sourcemaps for browser debugging; publishing them is
- // what the payload policy forbids, so the bundle ships without them.
- '@deepseek-ai/dsh-web-frontend': ['dist', '!dist/**/*.map'],
+ // Sourcemaps stay out by payload policy; the worker-preview surface
+ // (dist/preview.html and dist/preview/) backs private experimental
+ // packages and is not published.
+ '@deepseek-ai/dsh-web-frontend': ['dist', '!dist/**/*.map', '!dist/preview.html', '!dist/preview'],
}
/** The subset of package.json fields this constraint check cares about. */
diff --git a/tsconfig.host.json b/tsconfig.host.json
index 65de682575..687a5fda1b 100644
--- a/tsconfig.host.json
+++ b/tsconfig.host.json
@@ -36,6 +36,7 @@
"apps/web/tests/workspace-management.e2e.ts",
"apps/web/tests/replay-round-trip.e2e.ts",
"apps/web/tests/hmr-live.e2e.ts",
+ "apps/web/tests/preview-boot.e2e.ts",
"apps/web/tests/seeded-history.e2e.ts",
"apps/web/tests/cold-blank-session.e2e.ts",
"apps/web/tests/stats-paged-history.e2e.ts",