Boot the Preview Worker from the shipped profile and fetch lazy chunks on demand
Preview Worker 按正式 profile 启动,懒加载 chunk 按需拉取
The browser Preview ([webworker-runtime](../../../../packages/experimental/webworker-runtime/README.md), [webworker-packer](../../../../packages/experimental/webworker-packer/README.md)) can receive its boot configuration at pack time: the packer runs `dsh --profile web --dump-config` against a temporary home and writes the output to the image's `config/cordis.yml`; the Worker then inserts the whole configuration bac
English
Problem
The browser Preview (webworker-runtime, webworker-packer) can receive its boot configuration at pack time: the packer runs dsh --profile web --dump-config against a temporary home and writes the output to the image's config/cordis.yml; the Worker then inserts the whole configuration back as the profile's own patch (one insert). Measured on such an image, this has three consequences:
| Symptom | Cause |
|---|---|
| The Plugins page shows no optional bundle | The image only collects rows that appear in the dump, so none of the 11 default-off OPTIONAL_BUNDLES is included; the Worker's installAnchor points at the profile's own package.json, which declares no installation dependencies |
| Even when added, some bundles do not take effect after they are enabled | Bundle layers apply before the profile patch, when the base rows do not exist yet: the session-titles disable patch is skipped, agent-team produces 4 entry not found warnings, and rows a bundle inserts move to the front |
| The image is 12.79 MiB | 4 lazily loaded chunks (documentpreview's pdf, excel, and frontmatter chunks and sidebar-terminal's terminal chunk, about 4.4 MiB together) download together with the code needed at startup |
Measured: with no bundle enabled, the two compositions differ in only one of 188 rows, the compression: none setting of session-persistence-jsonl. The differences come from how the configuration is layered, not from the data itself.
Decision
Boot the same profile as the served Web deployment
The served launch path (apps/cli, app-boot) carries no Preview-specific code; the webworker packages reproduce it:
- packer
- Package roster =
PROFILE_TEMPLATES[profile].bundles+OPTIONAL_BUNDLES+ the rows named in those bundles' patch files; the bundle packages themselves also enter the image (patches, locale, and icon through the publish view). Both tables are read from app-boot, the same source the Plugins page uses. - The image carries a manifest-only
node_modules/@deepseek-ai/dsh/package.json(a verbatim copy ofapps/cli/package.json) as the installation anchor, and the image manifest names it ininstallation. - The image carries no composed configuration and no
config/cordis.yml; the image manifest records theprofilethe Worker boots. - The image carries a home patch,
home/cordis.patch.yml: for each row the runtime'sPLATFORM_ENTRY_CONFIGnames (session-persistence-jsonl), the config the template's bundle layers compose with the required fields over it (compression: none). A patch replaces a row's whole config, so the composed fields stay; the bundle files read at pack time are the ones in the image.
- Package roster =
- Worker
- The image's app-boot
loadProfileinitializeshome/profiles/<profile>from the template, the Worker writes an empty rootcordis.yml, andreadProfilePatchescomposes the layers. - profileContext is filled the way
runProfilefills it: name, dir, patchPath, installAnchor, startedBundles, cwd, home, and an empty overlay list, so the Worker callsreadProfilePatchesonce, as the launcher does without--patch. - The Worker provides a launchEnvironment snapshot and mounts
PluginPackageswithout a resolution.
- The image's app-boot
- Retained platform differences: jsonl
compression: nonethrough the image's home patch (ConfigEditor rejects edits to that row's config, as it rejects any row a home patch overrides), the module loader (the Worker's own CommonJS loader, provided through the Node internal-module interfaces in the next section), log output, command-line arguments, appReady timing, no pnpm, and no process-level proxy or signal handling.
The Worker uses existing app-boot exports; app-boot has no Preview override or injection point.
Node internal-module interfaces in the Worker
Plugin metadata reads (readPluginMeta), configuration schema dumps, and HMR do not go through ctx: they take Node internal modules directly through ModuleLoader.fromInternal() or node-addon-require-builtin.requireBuiltin(id). A Worker that hides those modules, for example by reporting process.versions.node as 0.0.0 so that fromInternal() returns undefined, breaks these callers once PluginPackages is mounted: every bundle on the Plugins page reports "Plugin metadata requires the Node module resolver", and HMR, which finds no loadCache, returns restart-required for every switch.
- Interfaces only: the Worker replaces
node-addon-require-builtinwith a static module whoserequireBuiltin(id)answers the 5 modules callers read, under Node v24.18.0 ids, with Node's member names and signatures; every other id throwsERR_FEATURE_UNAVAILABLE_ON_PLATFORM.process.versions.nodereports24.18.0, andexecArgvcarries no--expose-internals. internal/modules/esm/loader: the cascaded loader'sresolveSync(parentURL, request)andimportdelegate to the Worker's existing loader, and failed resolution carries Node'sERR_MODULE_NOT_FOUNDorERR_PACKAGE_PATH_NOT_EXPORTED;loadCacheis an emptyLoadCache;getOrCreateModuleJobexists and refuses when called. The Worker loader instance owns the cascaded loader.- Members HMR replaces:
Modulefrominternal/modules/cjs/loader,package_json_reader, themodulesbinding frominternal/bootstrap/realm, andResolveCachefrommodule_mapkeep Node's member names so HMR can replace and restore them; they refuse when called. The Worker's ownrequiredoes not route through them. - One cascaded loader: the Worker does not override
ctx.loader.internal; the Cordis Loader and static callers reach one cascaded loader throughfromInternal(). - Node behavior for measured callers:
node:path.win32.isAbsolute(icon path validation) andnode:util.inherits(http-errors), both following Node's source.
Inspector degradation on platforms without worker_threads
The Worker's structural stubs throw errors carrying ERR_FEATURE_UNAVAILABLE_ON_PLATFORM. The Inspector Host degrades only when its Worker fails to start with that code: it warns once, the bootstrap route answers 503, and it registers no DevTools route, injects nothing, and provides no inspector service; every other startup failure fails the fiber. serve-static and open load after the Worker starts, so a degraded start never loads them. Session Inspector in the same bundle therefore runs.
Separate archives for lazily loaded chunks
- Classification: the client build preset emits package-local chunk requests only as
require.async("./client.<name>.js"), andverify-client-packagesforbids synchronous chunk requires. The packer'slazyChunksOfscans every page asset in the swept image (each package'slib/client.jsandlib/client.*.js) for these literals, so a chunk that another chunk requests is found without following requests from the./cliententry. A target that a package's./clientexport names under any condition (such asclient.browser.js) loads eagerly and stays in the base image; every other target leaves it. A request for a chunk the package does not publish fails the pack. - Archives: each chunk gets one deterministic tar.gz named
lazy/<sha256>.tar.gzafter the SHA-256 of the compressed archive. The base manifest'slazylist records each archive as{ archive, files: [{ path, size }] }, with no file mode. There is no size threshold. - Registration: the Worker registers each listed file with
MemoryVfs.deferArchive, which creates its parent directories and gives it the VFS default file mode; an image that lists lazy archives must be fetched from a URL, against which the archive names resolve.exists,stat, andreaddiranswer synchronously from the registration. - Fetch: the asynchronous faces of the Worker's
node:fsandnode:fs/promisesthat read a registered file wait for its archive:fs.promises.readFile,fs.promises.open(so every FileHandle opens after arrival),fs.promises.cp, andfs.createReadStream. The first of them fetches the archive, inflates it, checks that it carries exactly the registered paths at the registered sizes, and materializes the files; concurrent readers share one fetch. The digest in the archive name is not verified. A failed fetch fails only the reads waiting on it; the files stay registered and the next asynchronous read fetches again. - Before arrival: synchronous reads, and writes, appends, truncation, chmod, renames, and links in their synchronous and promise forms, are refused with
ERR_DSH_VFS_DEFERRED, naming the archive; a directory rename that would move a registered file is refused before the destination changes, so the destination stays as it was. Unlink and recursive removal drop the registration, and seeding the path replaces it; an archive that arrives afterwards leaves those paths as they are. - client-modules:
chunkResponsereads a chunk withfs.promises.readFileinsidelazyBody, which shares one read among concurrent requests and drops a rejected read once it settles, so the next request reads again. Under Node the asynchronous read returns the same bytes as a synchronous one.
Watching and Client plugin delivery
- Worker
fs.watchandfs.watchFilelisteners run in the async context where the watcher was created, as Node delivers watch events from the watch handle, not in the context of the code whose write produced the event. HMR's configuration watcher therefore runs outside the writer's HMR transaction, and a configuration write made inside a transaction does not make the follow-up reload fail as a nested transaction. - The browser half of
@deepseek-ai/dsh-client-hmrreads/plugins/eventsthrough the page's__DSH_TRANSPORT__.fetchwhen a shell provides one (the Worker preview tunnel), otherwise throughEventSource. The Client plugins of a bundle enabled in the Preview therefore mount without a page reload.
Alternatives considered
Keep the pack-time composition and wrap it in a synthetic base bundle inside the Worker. The change is smaller, but the synthetic bundle appears in the plugin list, the self-composed pack-time configuration remains, and the Preview still differs from the served Web deployment.
Fetch chunks with synchronous XHR in the Worker. client-modules stays unchanged, but the whole Host tree blocks while the download runs.
Prefetch in the tunnel by recognizing chunk request URLs. The tunnel would need a copy of client-modules' /plugins/<id>/<file> routing rules.
Combine archives per package or above a size threshold. Each require.async needs exactly one file, so one archive per chunk fits best; a threshold would be a tunable with no supporting evidence.
Port Node v24's CommonJS loader and resolver into the Worker and replace the Worker's whole require pipeline. Resolution would match Node exactly, but it requires Node's condition set (changing image contents), cjs-module-lexer for named exports, and about 300 KB more in worker.js, while callers need only the internal-module interfaces.
Inject a resolver into readPluginMeta. It changes an app-boot public function to accommodate a platform difference, and other fromInternal() callers such as the configuration schema dump still get no loader.
Emulate --expose-internals. Without that flag, the vendored Loader, HMR, and app-boot all fall back to node-addon-require-builtin, so replacing that package covers every caller.
Consequences
The Preview boots the composition the served Web deployment boots. With no bundle enabled, the Worker composition differs from the served Web composition only by the jsonl platform override, and enabling any bundle skips no patch. The Plugins page and Settings → Built-in plugins list every OPTIONAL_BUNDLES entry with its title and icon and no metadata error. Switching each of the 11 optional bundles on and then off returns applied both times; after enabling, every enabled row it changes is active, and after disabling, the plugin list returns to its prior rows. Enabling session-titles disables @deepseek-ai/dsh-session-title-first-prompt-llm (row session-title-llm) and enables @deepseek-ai/dsh-session-title-all-prompts-llm, as in the served Web deployment; enabling inspector-profile runs Session Inspector and starts the NodeJS Inspector degraded. Degradation requires ERR_FEATURE_UNAVAILABLE_ON_PLATFORM, so a served dsh web runs the full Inspector.
Size (MiB, measured): the base image is 9.20 (12.79 with pack-time composition and eager chunks, −28%) including 11 optional bundles, plus 5 lazy tar archives totaling 4.45 (pdf 2.88, excel 1.39, terminal 0.13, frontmatter 0.05, Inspector bottom panel 0.00). The base image contains no lazily loaded chunk, and the page fetches an archive only when it requests the chunk.
The trade-off costs:
- HMR replaces no modules: the Worker's ES module cache stays empty, so a module file changed in the VFS keeps running its loaded code while HMR reports
applied. The Preview image changes no module files (package operations fail with theexecarefusal). - The Node internal-module members that exist only by name refuse when called; a future caller that invokes them fails explicitly in the Preview.
- Reporting
process.versions.nodeas24.18.0can change third-party code that branches on the version; a scan of the image found only undici selecting wasm SIMD by version on ppc64, which does not apply to the Worker. - CommonJS
require('stream')returns the module namespace rather than theStreamconstructor, so code that passes it toutil.inheritsfails at load. - Capabilities in optional bundles that cannot run in a browser (sqlite, terminals, local speech recognition) fail when used: enabling them reports no error, and using them does. A degraded NodeJS Inspector keeps only its bootstrap route, which answers 503.
- The image layout carries no
config/cordis.yml,WorkerHostOptionshas noconfigPath, and an image manifest withoutprofilefails validation, so an image packed with a composed configuration must be repacked. - Deferred files require readers to use the waiting asynchronous faces; a new synchronous reader fails explicitly in the Preview instead of degrading. The Worker checks only the registered paths and sizes of an arrived archive, not the digest that names it.
Testing
- profile-image.spec.ts pins the roster (template bundles, every optional bundle, and the packages their patch rows name) and an image that records the profile and installation and carries only the installation manifest.
- lazy-chunks.spec.ts pins one archive per
require.asyncchunk, identical archive names and bytes across packs, refusal of an unpublished chunk, and./clientexport targets kept in the base image. - lazy-archive.spec.ts pins registration answers,
ERR_DSH_VFS_DEFERREDrefusals, the unchanged rename destination, removal and seeding, one shared fetch, retry after a failed fetch, and the waitingnode:fsfaces. - require-builtin.spec.ts pins the provided internal modules, refusal of other ids with the platform code, the cascaded loader, the members HMR replaces, and the reported Node version; fs-watch-context.spec.ts pins watcher listeners running in the watcher's creation context.
- bootstrap-route.host.spec.ts pins Inspector degradation; transport.client.spec.ts pins the page-transport event stream; node-half.client.spec.ts pins the chunk read retry.
- preview-boot.e2e.ts boots the packed Worker deployment, requests a lazy chunk through the tunnel, switches every optional bundle on and off, checks the session-titles and inspector-profile rows, and mounts an enabled bundle's Client plugin without a reload. The image sizes are measured, not pinned by a test.
中文
问题
浏览器 Preview(webworker-runtime、webworker-packer)的启动配置可以在打包时生成:packer 用临时 home 运行 dsh --profile web --dump-config,把输出写进镜像的 config/cordis.yml;Worker 再把整份配置当作 profile 自己的补丁(一条 insert)插回去。在这样的镜像上实测,会带来三个后果:
| 现象 | 原因 |
|---|---|
| 插件页看不到任何 optional bundle | 镜像只收 dump 里出现的行,11 个默认关闭的 OPTIONAL_BUNDLES 都不在里面;Worker 的 installAnchor 指向 profile 自己的 package.json,没有安装依赖 |
| 即使补上,启用后有的 bundle 也不生效 | bundle 层排在 profile 补丁前面,基础行这时还不存在:session-titles 的禁用补丁被跳过,agent-team 产生 4 条 entry not found,bundle 新增的行排到了最前面 |
| 镜像 12.79 MiB | 4 个懒加载 chunk(documentpreview 的 pdf、excel、frontmatter,sidebar-terminal 的 terminal,共约 4.4 MiB)和启动必需的代码一起下载 |
实测:不开 bundle 时,两边组合的 188 行里只有 session-persistence-jsonl 的 compression: none 一处不同。差异来自配置的分层方式,不在数据本身。
决策
与正式 Web 相同的 profile 启动
正式启动链路(apps/cli、app-boot)不含 Preview 专用代码;由 webworker 包复现这条链路:
- packer
- 收包清单 =
PROFILE_TEMPLATES[profile].bundles+OPTIONAL_BUNDLES+ 这些 bundle 补丁文件里的行;bundle 包本身也打进镜像(补丁、locale、icon 走 publish view)。两张表从 app-boot 读,与插件页同一来源。 - 镜像里放一份只含清单的
node_modules/@deepseek-ai/dsh/package.json(原样复制apps/cli/package.json),作为安装锚点,镜像 manifest 在installation里记录它。 - 镜像不带组合好的配置,也没有
config/cordis.yml;镜像 manifest 记录 Worker 要启动的profile。 - 镜像带一份 home patch
home/cordis.patch.yml:对运行时PLATFORM_ENTRY_CONFIG列出的每一行(session-persistence-jsonl),写入模板各 bundle 层合成出的配置,并在其上设置所需字段(compression: none)。补丁会整体替换一行的配置,所以保留合成出的字段;打包时读取的 bundle 文件就是镜像里的那些。
- 收包清单 =
- Worker
- 用镜像里 app-boot 的
loadProfile从模板初始化home/profiles/<profile>,写空的根cordis.yml,用readProfilePatches组合。 - profileContext 按
runProfile填写:name、dir、patchPath、installAnchor、startedBundles、cwd、home,overlay 列表为空,因此 Worker 与不带--patch的启动器一样只调用一次readProfilePatches。 - 提供 launchEnvironment 快照,挂上不带 resolution 的
PluginPackages。
- 用镜像里 app-boot 的
- 保留的平台差异:jsonl
compression: none(经镜像的 home patch 提供;ConfigEditor 会拒绝修改这一行的配置,与拒绝任何被 home patch 覆盖的行一致)、模块加载器(Worker 自己的 CommonJS 加载器,经 Node internal 模块接口提供,见下节)、日志输出、启动参数、appReady 时机、没有 pnpm、没有进程级代理和信号处理。
Worker 只用 app-boot 现有导出;app-boot 没有 Preview 专用的 override 或注入点。
Worker 提供 Node internal 模块接口
读插件元信息(readPluginMeta)、配置 schema 导出和 HMR 不经过 ctx,而是用 ModuleLoader.fromInternal() 或 node-addon-require-builtin.requireBuiltin(id) 直接取 Node 的 internal 模块。如果 Worker 藏起这些模块,例如把 process.versions.node 报成 0.0.0 让 fromInternal() 返回空,挂上 PluginPackages 后这些调用方就会出错:插件页每个 bundle 都报“Plugin metadata requires the Node module resolver”,HMR 看不到 loadCache,每次开关都返回 restart-required。
- 只照搬接口:Worker 用静态模块替换
node-addon-require-builtin,requireBuiltin(id)按 Node v24.18.0 的 id 返回调用方实际读取的 5 个模块,成员名和签名与 Node 一致;其余 id 抛ERR_FEATURE_UNAVAILABLE_ON_PLATFORM。process.versions.node报24.18.0,execArgv不带--expose-internals。 internal/modules/esm/loader:cascaded loader 的resolveSync(parentURL, request)和import交给 Worker 现有的加载器,解析失败带 Node 的ERR_MODULE_NOT_FOUND、ERR_PACKAGE_PATH_NOT_EXPORTED;loadCache是空的LoadCache;getOrCreateModuleJob存在但调用即拒绝。cascaded loader 由 Worker 加载器实例持有。- HMR 替换的成员:
internal/modules/cjs/loader的Module、package_json_reader、internal/bootstrap/realm的modulesbinding、module_map的ResolveCache保留 Node 的成员名,供 HMR 替换与还原;被调用时拒绝。Worker 自己的require不经过它们。 - 同一个 cascaded loader:Worker 不覆写
ctx.loader.internal;Cordis Loader 与静态调用方经fromInternal()拿到同一个 cascaded loader。 - 按实测调用方补齐的 Node 行为:
node:path.win32.isAbsolute(图标路径校验)、node:util.inherits(http-errors),均按 Node 源码实现。
Inspector 在没有 worker_threads 的平台上降级
Worker 的结构化 stub 抛出带 ERR_FEATURE_UNAVAILABLE_ON_PLATFORM 的错误。Inspector Host 只在 Worker 启动以这个错误码失败时降级:警告一次,bootstrap 路由返回 503,不注册 DevTools 路由、不注入、不提供 inspector 服务;其他启动错误让 fiber 失败。serve-static 和 open 在 Worker 启动成功后才加载,降级时不加载。同一 bundle 里的 Session Inspector 因此正常运行。
懒加载 chunk 独立归档
- 分类:client 构建预设只以
require.async("./client.<name>.js")的形式产出包内 chunk 请求,verify-client-packages禁止同步 require chunk。packer 的lazyChunksOf扫描清扫后镜像里的每个页面资源(各包的lib/client.js和lib/client.*.js)中的这些字面量,因此 chunk 引用的 chunk 不必从./client入口沿请求追踪也能找到。在任一条件下被某个包的./client导出指向的目标(如client.browser.js)会被立即加载,留在基础镜像;其余目标移出基础镜像。请求包未发布的 chunk 会让打包失败。 - 归档:每个 chunk 一个确定性 tar.gz,按压缩后归档的 SHA-256 命名为
lazy/<sha256>.tar.gz。基础 manifest 的lazy列表把每个 archive 记录为{ archive, files: [{ path, size }] },不记录文件 mode。不设大小阈值。 - 登记:Worker 用
MemoryVfs.deferArchive登记列出的每个文件,同时创建其父目录,并赋予 VFS 默认文件 mode;列出懒加载 archive 的镜像必须从 URL 拉取,archive 名相对这个 URL 解析。exists、stat、readdir依据登记同步作答。 - 拉取:Worker 的
node:fs和node:fs/promises中读取已登记文件的异步接口会等待其 archive:fs.promises.readFile、fs.promises.open(因此每个 FileHandle 都在到达后才打开)、fs.promises.cp和fs.createReadStream。其中第一个调用拉取 archive、解压,校验其中恰好是登记的路径和登记的大小,再物化文件;并发读取方共用一次拉取。archive 名里的摘要不做校验。拉取失败只让等待它的读取失败;文件保持登记,下一次异步读取再次拉取。 - 到达之前:同步读取,以及同步和 promise 形式的写入、追加、截断、chmod、重命名和硬链接,都以
ERR_DSH_VFS_DEFERRED拒绝,并给出 archive 名;会移动已登记文件的目录重命名在目标改变之前就被拒绝,目标保持原样。unlink 和递归删除会撤销登记,对该路径 seed 会替换登记;之后到达的 archive 不改动这些路径。 - client-modules:
chunkResponse在lazyBody里用fs.promises.readFile读取 chunk;lazyBody让并发请求共用一次读取,被拒绝的读取一旦落定就丢弃,下一次请求重新读取。在 Node 下,异步读取与同步读取得到相同字节。
文件监听与 Client 插件下发
- Worker 的
fs.watch和fs.watchFile监听器在创建 watcher 时的异步上下文里运行,与 Node 从 watch 句柄派发事件一致,而不是在写入方的上下文里运行。因此 HMR 的配置 watcher 在写入方的 HMR 事务之外运行,事务内的配置写入不会让随后的重载因嵌套事务而失败。 @deepseek-ai/dsh-client-hmr的浏览器部分在 shell 提供__DSH_TRANSPORT__.fetch时(Worker preview 隧道)经它读取/plugins/events,否则经EventSource读取。因此在 Preview 里启用 bundle 后,其 Client 插件无需刷新页面即可挂载。
考虑过的替代方案
保留打包期组合,只在 Worker 里包成一个合成 base bundle。 改动小,但合成 bundle 会出现在插件列表里,打包期“自己编”的配置也还在,与正式 Web 不同构。
Worker 用同步 XHR 拉 chunk。 不改 client-modules,但下载期间整棵 Host 树都被阻塞。
隧道按 URL 识别 chunk 请求后预取。 需要在隧道里复制 client-modules 的 /plugins/<id>/<file> 路由规则。
按包或按大小阈值合并 archive。 每次 require.async 只要一个文件,按 chunk 拆分最贴合;阈值是没有依据的可调参数。
把 Node v24 的 CJS 加载器和解析器源码搬进 Worker,替换 Worker 的整条 require 流程。 解析路径可以与 Node 逐字一致,但要改用 Node 的条件集(镜像内容随之变化)、引入 cjs-module-lexer 计算具名导出,worker.js 约增加 300 KB;而调用方只需要 internal 模块的接口。
给 readPluginMeta 注入解析器。 要改 app-boot 的公共函数来适配一个平台差异,配置 schema 导出等其他 fromInternal() 调用方仍然拿不到加载器。
模拟 --expose-internals。 vendor Loader、HMR 和 app-boot 在没有这个参数时都退到 node-addon-require-builtin,替换这个包即可覆盖全部调用方。
后果
Preview 启动的组合与正式 Web 相同。不开 bundle 时,Worker 组合与正式 Web 只差 jsonl 的平台覆盖;开任一 bundle 都没有被跳过的补丁。插件页和“设置 → 内置插件”列出全部 OPTIONAL_BUNDLES,每个都显示标题和图标,没有元信息错误。11 个 optional bundle 逐个开启再关闭,两次都返回 applied;开启后它改动的每个启用行都是 active,关闭后插件列表恢复原样。启用 session-titles 会禁用 @deepseek-ai/dsh-session-title-first-prompt-llm(行 session-title-llm)并启用 @deepseek-ai/dsh-session-title-all-prompts-llm,与正式 Web 一致;启用 inspector-profile 后 Session Inspector 运行,NodeJS Inspector 降级启动。降级以 ERR_FEATURE_UNAVAILABLE_ON_PLATFORM 为前提,所以正常 dsh web 运行完整的 Inspector。
体积(MiB,实测):基础镜像 9.20(打包期组合且 chunk 随启动下载时为 12.79,−28%),含 11 个 optional bundle;另有 5 个 lazy tar 共 4.45(pdf 2.88、excel 1.39、terminal 0.13、frontmatter 0.05、inspector 底部面板 0.00)。基础镜像不含懒加载 chunk,页面请求 chunk 时才拉取对应 archive。
这一取舍的代价:
- HMR 不替换模块:Worker 的 ES module 缓存始终为空,VFS 里被修改的模块文件仍运行已加载的代码,而 HMR 照常报告
applied。Preview 镜像不会修改模块文件(包操作会因execa拒绝而失败)。 - Node internal 模块里只提供成员名的部分被调用时拒绝;将来有调用方真正调用它们,会在 Preview 里明确失败。
process.versions.node报24.18.0可能改变按版本分支的第三方代码;扫描镜像只发现 undici 在 ppc64 上按版本选择 wasm SIMD,与 Worker 无关。- CommonJS 的
require('stream')返回模块命名空间而不是Stream构造函数,把它传给util.inherits的代码会在加载时失败。 - optional bundle 里浏览器跑不了的能力(sqlite、终端、本地语音)按“跑到才炸”处理:启用不报错,用到时报错。NodeJS Inspector 降级后只剩返回 503 的 bootstrap 路由。
- 镜像布局里没有
config/cordis.yml,WorkerHostOptions没有configPath,缺少profile的镜像 manifest 无法通过校验,所以带组合配置打出的镜像必须重新打包。 - 延迟文件要求读取方走会等待的异步接口;新增的同步读取方会在 Preview 里明确失败,而不是退化。Worker 只校验到达的 archive 中登记的路径和大小,不校验命名它的摘要。
测试
- profile-image.spec.ts 锁定收包清单(模板 bundle、全部 optional bundle、它们补丁行指向的包),以及记录 profile 和 installation、只带安装清单的镜像。
- lazy-chunks.spec.ts 锁定每个
require.asyncchunk 一个 archive、多次打包得到相同的 archive 名和字节、拒绝未发布的 chunk、./client导出目标留在基础镜像。 - lazy-archive.spec.ts 锁定登记后的作答、
ERR_DSH_VFS_DEFERRED拒绝、重命名目标保持不变、删除与 seed、共用一次拉取、拉取失败后重试,以及会等待的node:fs接口。 - require-builtin.spec.ts 锁定提供的 internal 模块、其他 id 以平台错误码拒绝、cascaded loader、HMR 替换的成员和报告的 Node 版本;fs-watch-context.spec.ts 锁定监听器在 watcher 创建时的上下文里运行。
- bootstrap-route.host.spec.ts 锁定 Inspector 降级;transport.client.spec.ts 锁定经页面 transport 读取事件流;node-half.client.spec.ts 锁定 chunk 读取重试。
- preview-boot.e2e.ts 启动打包好的 Worker 部署,经隧道请求一个懒加载 chunk,逐个开关全部 optional bundle,检查 session-titles 和 inspector-profile 的行,并在不刷新页面的情况下挂载已启用 bundle 的 Client 插件。镜像体积是实测值,没有测试锁定。