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",