From 50bfb00985db4e756f99ba9f89200326db633507 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:20:27 +0800 Subject: [PATCH] feat(web): single-build preview page and its acceptance e2e One Vite build emits dist/index.html and dist/preview.html sharing every chunk; the only difference is one prepended bootstrap entry whose module connects the worker host, so the page from the stock entry onward is the served startup chain verbatim. The dist moves to a relative base so the preview mounts under any static directory, and the served form anchors deep SPA-fallback paths with a rendered . The preview-boot e2e serves the real built pages, packs the VFS image when absent, and holds the boot line's lowering contract, the interactive hero, and a clean page-error channel in headless Chromium. --- ...worker-pack-lowering-and-preview.i18n.yaml | 6 + ...-20-webworker-pack-lowering-and-preview.md | 34 +++ ...-webworker-pack-lowering-and-preview.zh.md | 34 +++ apps/web/package.json | 15 +- apps/web/src/preview.ts | 12 + apps/web/src/vite-env.d.ts | 1 + apps/web/tests/preview-boot.e2e.ts | 242 ++++++++++++++++++ apps/web/tests/pwa-manifest.e2e.ts | 2 +- apps/web/tsconfig.json | 1 + apps/web/vite.config.ts | 55 +++- packages/host/frontend-static/src/index.ts | 14 +- .../tests/frontend-static.spec.ts | 4 +- scripts/check-workspace-constraints.ts | 7 +- tsconfig.host.json | 1 + 14 files changed, 416 insertions(+), 12 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md create mode 100644 .agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md create mode 100644 apps/web/src/preview.ts create mode 100644 apps/web/src/vite-env.d.ts create mode 100644 apps/web/tests/preview-boot.e2e.ts 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",