diff --git a/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.i18n.yaml
index 90d1f96dff..d3fab99c56 100644
--- a/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.i18n.yaml
@@ -2,5 +2,5 @@
# 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-19-web-index-injection-table.md
-2026-08-19-web-index-injection-table.md: 9ed02aa94cd318d107a32802d8723652e6b10ea2
-2026-08-19-web-index-injection-table.zh.md: 8ad036766faa14071b20da12ef907ab012cae23f
+2026-08-19-web-index-injection-table.md: 050690946a73ce453946f6d1152c9e86e1ba3aaf
+2026-08-19-web-index-injection-table.zh.md: 27d1cbce1dc39ebe22e0b79e52f9ce935279ee69
diff --git a/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.md b/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.md
index 9ed02aa94c..050690946a 100644
--- a/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.md
+++ b/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.md
@@ -10,16 +10,16 @@ The web shell's boot HTML needs three kinds of injection: client-modules' boot p
## Decision
-Make the injection surface an event over pure data: the webserver declares the `webserver/index-inject` event and the `IndexInjection` row union (`global`/`script`/`script-src`/`style`/`html`, `head|body` placement). A plugin that wants to inject subscribes and pushes rows; every collection (`collectIndexInjections()`) is a fresh emit, so subscribers read live state at emit time (module graph, theme preference — no re-registration staleness), and a subscription dies with its fiber.
+Make the injection surface an event over pure data: the webserver declares the `webserver/index-inject` event and the `IndexInjection` row union (`global`/`script`/`script-src`/`script-preload`/`style`/`html`, with placement where applicable). A plugin that wants to inject subscribes and pushes rows; every collection (`collectIndexInjections()`) is a fresh emit, so subscribers read live state at emit time (module graph, theme preference — no re-registration staleness), and a subscription dies with its fiber.
-One table, two renderers: the served form's `webServer.renderIndex(html)` renders rows into index.html deterministically (head rows after the opening head tag, body rows after the opening body tag; `<` JSON-escaped in global values, attribute-escaped `src`); the worker form's `/__boot__` payload is `{ injections }`, executed row by row by a small page-side interpreter (set global / create script element / load external through the tunnel's `loadBundle` / mount style and markup). Rows are pure JSON data — that is the both-ends-equivalent discipline.
+One table, two renderers: the served form's `webServer.renderIndex(html)` renders rows into index.html deterministically (head rows after the opening head tag, body rows after the opening body tag; `<` JSON-escaped in global values, attribute-escaped `src`); the worker form's `/__boot__` payload is `{ injections }`, executed row by row by a small page-side interpreter (set global / create script element / load external through the tunnel's `loadBundle` / mount style and markup). A `script-preload` row renders a browser preload hint in served HTML and is ignored by the worker interpreter, whose `/plugins` resources exist only behind the tunnel and load on demand. Rows are pure JSON data — that is the both-ends-equivalent discipline.
`tapIndex`/`applyIndexTaps` survive as the raw-HTML escape hatch, applied after row rendering; every internal consumer moved to the event.
## Consequences
- client-modules and ui-theme no longer regex-edit HTML; the worker's `readBootPayload` service-poking (`clientModules`, `settings`, theme constants through `loader.load`) is deleted; the page-side `installModuleLoaderFacade`, `applyBootTheme`, and `PARSER_PRELOAD_IDS` re-implementations retire.
-- Ordering: across subscribers, subscription order (same as the old tap order); within one subscriber, push order — modules itself guarantees queue → preloads → global.
+- Ordering: across subscribers, subscription order (same as the old tap order); within one subscriber, push order — modules itself guarantees queue → application preload → bootstrap script → global.
- The served rendering of the manifest global changed from `window.__DSH_BOOT__ =` to `globalThis["__DSH_BOOT__"] =`; no committed snapshot expectation carries that text, so none needed re-recording.
- New model-visible or page-visible boot inputs extend the row union; no new tap consumers.
diff --git a/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.zh.md b/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.zh.md
index 8ad036766f..27d1cbce1d 100644
--- a/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.zh.md
+++ b/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.zh.md
@@ -10,16 +10,16 @@ Web 壳的启动 HTML 需要三类注入:client-modules 的引导协议(`__M
## Decision
-注入面事件化、数据化:webserver 声明 `webserver/index-inject` 事件与纯数据行类型 `IndexInjection`(`global`/`script`/`script-src`/`style`/`html`,`head|body` 定位)。想注入的插件订阅事件、往表里 push 行;每次收集(`collectIndexInjections()`)都是一次全新 emit,订阅方现读现填(模块图、主题偏好天然新鲜,无重注册问题),订阅随 fiber 销毁自动摘除。
+注入面事件化、数据化:webserver 声明 `webserver/index-inject` 事件与纯数据行类型 `IndexInjection`(`global`/`script`/`script-src`/`script-preload`/`style`/`html`,在适用的行上携带定位)。想注入的插件订阅事件、往表里 push 行;每次收集(`collectIndexInjections()`)都是一次全新 emit,订阅方现读现填(模块图、主题偏好天然新鲜,无重注册问题),订阅随 fiber 销毁自动摘除。
-一张表两个渲染器:served 形态 `webServer.renderIndex(html)` 确定性把行渲染进 index.html(head 行插 head 首、body 行插 body 首,全局值 JSON `<` 转义、src 属性转义);worker 形态 `/__boot__` 载荷就是 `{ injections }`,页面侧小解释器逐行执行(设全局 / 建脚本元素 / 经 tunnel loadBundle 载外链 / 挂样式与 DOM)。行是纯 JSON 数据,这是双端等价的纪律。
+一张表两个渲染器:served 形态 `webServer.renderIndex(html)` 确定性把行渲染进 index.html(head 行插 head 首、body 行插 body 首,全局值 JSON `<` 转义、src 属性转义);worker 形态 `/__boot__` 载荷就是 `{ injections }`,页面侧小解释器逐行执行(设全局 / 建脚本元素 / 经 tunnel loadBundle 载外链 / 挂样式与 DOM)。`script-preload` 行在 served HTML 中渲染为浏览器预加载提示;worker 解释器忽略它,因为 `/plugins` 资源只存在于 tunnel 后方,并在实际需要时加载。行是纯 JSON 数据,这是双端等价的纪律。
`tapIndex`/`applyIndexTaps` 保留为原始 HTML 变换的逃生口,在行渲染之后执行;内部消费者全部迁走。
## Consequences
- client-modules 与 ui-theme 不再各自正则改 HTML;worker 侧 `readBootPayload` 的 `ctx.get` 手掏(clientModules、settings、theme 常量 loader.load)删除;页面侧 `installModuleLoaderFacade`、`applyBootTheme`、`PARSER_PRELOAD_IDS` 三份重抄退役。
-- 顺序语义:跨订阅方按订阅注册顺序(与旧 tap 顺序一致),单订阅方内按 push 顺序;modules 自己保证 队列→preload→全局 三行有序。
+- 顺序语义:跨订阅方按订阅注册顺序(与旧 tap 顺序一致),单订阅方内按 push 顺序;modules 自己保证队列→application preload→bootstrap script→全局的顺序。
- `__DSH_BOOT__` 的 served 渲染文本从 `window.__DSH_BOOT__ =` 变为 `globalThis["__DSH_BOOT__"] =`;已核实无已提交快照期望含此文本,无需重录。
- 新的模型可见/页面可见注入一律走行类型扩展,不再新增 tap 消费者。
diff --git a/packages/client/modules/src/index.ts b/packages/client/modules/src/index.ts
index 27ce53e35a..f8344207e9 100644
--- a/packages/client/modules/src/index.ts
+++ b/packages/client/modules/src/index.ts
@@ -351,15 +351,6 @@ const CLIENT_MODULES_ID = '@deepseek-ai/dsh-client-modules'
/** Dynamic bundles grouped into the parser bootstrap batch before the Vite shell. */
const PARSER_PRELOAD_IDS = [CLIENT_MODULES_ID] as const
-/** Escape a graph URL before placing it in a quoted HTML attribute. */
-function escapeHtmlAttribute(value: string): string {
- return value
- .replaceAll('&', '&')
- .replaceAll('"', '"')
- .replaceAll('<', '<')
- .replaceAll('>', '>')
-}
-
/**
* The boot protocol as index injection rows. The inline registration queue
* precedes the application-batch preload and the blocking bootstrap batch. Its
@@ -398,11 +389,7 @@ window.__ModuleLoader__={
const application = graph.batches.find(batch => batch.phase === 'application')
const rows: IndexInjection[] = [{ kind: 'script', placement: 'head', text: queue }]
if (application !== undefined) {
- rows.push({
- kind: 'html',
- placement: 'head',
- html: ``,
- })
+ rows.push({ kind: 'script-preload', src: application.url })
}
if (bootstrap !== undefined) {
rows.push({ kind: 'script-src', placement: 'head', src: bootstrap.url })
diff --git a/packages/client/web/tests/boot.client.spec.ts b/packages/client/web/tests/boot.client.spec.ts
index 8d75949b78..34b13d40ad 100644
--- a/packages/client/web/tests/boot.client.spec.ts
+++ b/packages/client/web/tests/boot.client.spec.ts
@@ -106,50 +106,54 @@ describe('plugin activation', () => {
{ id: 'provider', url: '/provider.js', rev: '1' },
{ id: 'renderer', url: '/renderer.js', rev: '1' },
]
- win.__DSH_BOOT__ = { rev: 'graph', entries }
- target.load({
- id: 'runtime',
- factory: require => ({
- apply: () => {},
- marker: (require(PROVIDER_CLIENT_ID) as { marker: string }).marker,
- }),
- })
+ const applicationUrl = '/application.js'
+ win.__DSH_BOOT__ = {
+ rev: 'graph',
+ entries,
+ batches: [{ phase: 'application', url: applicationUrl, rev: 'batch', entries: entries.map(row => row.id) }],
+ }
const loaded: string[] = []
- const registrations = new Map([
- ['/consumer.js', {
+ const registrations: ClientBundleRegistration[] = [
+ {
id: 'consumer',
factory: require => ({
apply: () => {
expect((require(RUNTIME_CLIENT_ID) as { marker: string }).marker).toBe('provider')
},
}),
- }],
- ['/provider.js', {
+ },
+ {
id: 'provider',
factory: () => ({ apply: () => {}, marker: 'provider' }),
- }],
- ['/renderer.js', {
+ },
+ {
+ id: 'runtime',
+ factory: require => ({
+ apply: () => {},
+ marker: (require(PROVIDER_CLIENT_ID) as { marker: string }).marker,
+ }),
+ },
+ {
id: 'renderer',
factory: () => ({
apply: (ctx: Context) => {
ctx.reflect.provide('uiRenderer', { mount: () => () => {} })
},
}),
- }],
- ])
+ },
+ ]
transportGlobal.__DSH_TRANSPORT__ = {
loadBundle: async (url) => {
loaded.push(url)
- const registration = registrations.get(url)
- if (registration === undefined) throw new Error(`missing fixture registration ${url}`)
- target.load(registration)
+ if (url !== applicationUrl) throw new Error(`missing fixture batch ${url}`)
+ for (const registration of registrations) target.load(registration)
},
}
const entry = new AppWebEntry(container)
await entry.run()
- expect(loaded).toEqual(['/provider.js', '/consumer.js', '/renderer.js'])
+ expect(loaded).toEqual([applicationUrl])
await entry.dispose()
})
diff --git a/packages/experimental/webworker-runtime/README.i18n.yaml b/packages/experimental/webworker-runtime/README.i18n.yaml
index d0d0d13a6e..2da19ae9e3 100644
--- a/packages/experimental/webworker-runtime/README.i18n.yaml
+++ b/packages/experimental/webworker-runtime/README.i18n.yaml
@@ -2,5 +2,5 @@
# 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 packages/experimental/webworker-runtime/README.md
-README.md: 3e9b4fffe0b97a97adf218aa12fd1f4342d3bc6c
-README.zh.md: 2552c659d1b735b0cf28b9b0d0808276d31d0a2a
+README.md: f8789f8e82005c15cd38cbe77832e486d4a44b9b
+README.zh.md: e89ae7fd1e6b5d9ad9aa68edecfd92a81dcaf9b9
diff --git a/packages/experimental/webworker-runtime/README.md b/packages/experimental/webworker-runtime/README.md
index 3e9b4fffe0..f8789f8e82 100644
--- a/packages/experimental/webworker-runtime/README.md
+++ b/packages/experimental/webworker-runtime/README.md
@@ -9,7 +9,7 @@ Three artifacts from one tsdown pipeline:
- **`lib/index.js` (assembly library)** — `createWorkerHost`/`startWorkerHost` mount the base image and any ordered data overlays (`storage/`), install the module loader (`module-system/`) and the `process` shim, boot the tree through the image's own `dsh-app-boot`, and hand the tunnel its serving seams. Overlays may replace files only under `home/` and `workspace/`; they cannot replace the base manifest, configuration, or modules. The image layout contract (`image-layout.ts`: virtual root, config/manifest paths, empty directories, the `lowered` wrapper-contract gate) is shared with the packer. Boot patches force the deployment-shaped rows: frontend serving off, JSONL session logs on the plaintext path, preset roots onto the image's `config/agent-presets`.
- **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. `node:module` supplies `createRequire().resolve` and `.resolve.paths()` over the image package root, so unchanged packages can discover manifests without evaluating their modules. VFS mutations drive `node:fs` callback, polling, and promise watchers; open descriptors retain file identity and access mode across rename, replacement, and unlink; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount ([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)).
- **`src/shell/` (the worker's own process layer)** — a browser worker cannot fork, so `node:child_process` is not a stub but an implementation: `spawn` starts the command in its own Web Worker — this same bundle, told by its first frame to be a shell process — and reports it through the `ChildProcess` surface the subprocess service consumes. The command runs off the host's thread, `SIGKILL` terminates it whatever it is doing, and it reaches the VFS only by message (the host serves those frames). Worker platform executables preserve native-package protocols such as Landlock without replacing their JavaScript packages or coupling their implementations to `node:child_process`; ordinary commands use the package's evaluator and coreutils command table. The grammar is `@yarnpkg/parsers`' `parseShell`, while `execSync`/`fork` still refuse because they need a real process.
-- **`lib/client.js` (page half)** — startup has two independent stages. `chooseWorkerHostSource({ image?, fixtureManifest? })` optionally owns the boot barrier and fixture manifest: without `preview-fixture` it waits at the source chooser, while a valid query selects directly; either path returns ordered overlays. `connectWorkerHost(worker, { image?, overlays? })` remains the public base-runtime connector; callers that skip the chooser get an empty overlay list. `apps/web` invokes both and supplies its statically bundled Worker. The opening `init` frame carries the base and ordered overlay URLs, the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. The tunnel exposes fetch-shaped transport, the API client, and `loadBundle` for the shell's boot seam.
+- **`lib/client.js` (page half)** — startup has two independent stages. `chooseWorkerHostSource({ image?, fixtureManifest? })` optionally owns the boot barrier and fixture manifest: without `preview-fixture` it waits at the source chooser, while a valid query selects directly; either path returns ordered overlays. `connectWorkerHost(worker, { image?, overlays? })` remains the public base-runtime connector; callers that skip the chooser get an empty overlay list. `apps/web` invokes both and supplies its statically bundled Worker. The opening `init` frame carries the base and ordered overlay URLs, the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. Script preload rows are advisory and skipped because `/plugins` resources resolve only through the tunnel; `loadBundle` performs the actual fetch and execution on first demand. The tunnel also exposes fetch-shaped transport and the API client.
Acceptance lives in `apps/web/tests/preview-boot.e2e.ts`, which serves the real built pages and drives the pre-boot chooser plus Worker activation in headless Chromium. The empty selection exercises first-run startup. The `vfs-example` overlay supplies ordinary workspace files and plaintext persistence artifacts for cold Workspace/Session discovery, tool presentation, subagent navigation, and history paging without a model request. The chooser reserves WebFS as a separate user-authorized source; that provider does not read the built-in fixture.
diff --git a/packages/experimental/webworker-runtime/README.zh.md b/packages/experimental/webworker-runtime/README.zh.md
index 2552c659d1..e89ae7fd1e 100644
--- a/packages/experimental/webworker-runtime/README.zh.md
+++ b/packages/experimental/webworker-runtime/README.zh.md
@@ -9,7 +9,7 @@
- **`lib/index.js`(装配库)**——`createWorkerHost`/`startWorkerHost` 挂载基础镜像和按序排列的数据 overlays(`storage/`)、安装模块加载器(`module-system/`)与 `process` shim、经镜像自带的 `dsh-app-boot` 启动插件树,并把服务缝隙交给隧道。Overlay 只能替换 `home/` 与 `workspace/` 下的文件,不能替换基础 manifest、配置或模块。镜像布局契约(`image-layout.ts`:虚拟根、config/manifest 路径、空目录、`lowered` 包装契约门)与 packer 共享。boot patch 强制部署形态行:关前端静态服务、JSONL 会话日志走明文、preset 根指向镜像内 `config/agent-presets`。
- **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。`node:module` 在镜像 package 根之上提供 `createRequire().resolve` 与 `.resolve.paths()`,使未修改的包无需执行目标模块即可发现 manifest。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;打开的 descriptor 在 rename、replacement 和 unlink 后仍保留文件身份与访问模式;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。
- **`src/shell/`(worker 自己的进程层)**——浏览器 worker 无法 fork,所以 `node:child_process` 不是 stub 而是实现:`spawn` 把命令放进它自己的 Web Worker——就是这同一个束,由首帧告诉它「你是 shell 进程」——并以 subprocess 服务消费的 `ChildProcess` 面报告结果。命令不占宿主线程,`SIGKILL` 不管它在干什么都能终止它,而它只能靠消息触达 VFS(由宿主应答这些帧)。Worker 平台 executable 在不替换 JavaScript 包、也不把具体实现耦合进 `node:child_process` 的情况下保持 Landlock 等 native 包协议;普通命令使用本包的求值器与 coreutils 命令表。语法来自 `@yarnpkg/parsers` 的 `parseShell`,而 `execSync`/`fork` 依然拒绝,因为它们需要真进程。
-- **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URL,boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。隧道暴露 fetch 形传输、API 客户端与壳启动缝隙用的 `loadBundle`。
+- **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URL,boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。脚本 preload 行只是提示,因此会被跳过:`/plugins` 资源只能经 tunnel 解析,`loadBundle` 会在首次需要时完成实际获取与执行。Tunnel 还暴露 fetch 形传输与 API 客户端。
验收在 `apps/web/tests/preview-boot.e2e.ts`:静态服务真实构建页面,在 headless Chromium 里驱动 pre-boot 选择面板与 Worker 激活。空白选择验证首次启动;`vfs-example` overlay 提供普通 workspace 文件与明文 persistence 产物,无需模型请求即可验证 Workspace/Session 冷发现、工具呈现、subagent 导航和历史分页。选择面板为 WebFS 保留独立的用户授权来源;该 provider 不读取内置 fixture。
diff --git a/packages/experimental/webworker-runtime/src/client/apply-injections.ts b/packages/experimental/webworker-runtime/src/client/apply-injections.ts
index 163729a6aa..76e094cd60 100644
--- a/packages/experimental/webworker-runtime/src/client/apply-injections.ts
+++ b/packages/experimental/webworker-runtime/src/client/apply-injections.ts
@@ -34,6 +34,10 @@ export async function applyIndexInjections(
case 'script-src':
await loadScript(row.src)
break
+ case 'script-preload':
+ // The worker tunnel has no browser URL to warm without also executing
+ // the script; loadScript handles the real request when the row arrives.
+ break
case 'style': {
const el = document.createElement('style')
el.textContent = row.text
diff --git a/packages/experimental/webworker-runtime/tests/client/apply-injections.spec.ts b/packages/experimental/webworker-runtime/tests/client/apply-injections.spec.ts
new file mode 100644
index 0000000000..60a764e5e0
--- /dev/null
+++ b/packages/experimental/webworker-runtime/tests/client/apply-injections.spec.ts
@@ -0,0 +1,21 @@
+// @vitest-environment jsdom
+import { afterEach, expect, it, vi } from 'vitest'
+import { applyIndexInjections } from '../../src/client/apply-injections.ts'
+
+afterEach(() => {
+ document.head.innerHTML = ''
+ document.body.innerHTML = ''
+})
+
+it('ignores script preload hints and executes script sources through the worker loader', async () => {
+ const loadScript = vi.fn(async () => {})
+
+ await applyIndexInjections([
+ { kind: 'script-preload', src: '/plugins/preload.js' },
+ { kind: 'script-src', placement: 'head', src: '/plugins/execute.js' },
+ ], loadScript)
+
+ expect(loadScript).toHaveBeenCalledOnce()
+ expect(loadScript).toHaveBeenCalledWith('/plugins/execute.js')
+ expect(document.querySelector('link[rel="preload"]')).toBeNull()
+})
diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts
index 65f359f42a..5a190d87d1 100644
--- a/packages/extensions/tool-cordis/src/api-catalog.ts
+++ b/packages/extensions/tool-cordis/src/api-catalog.ts
@@ -3765,7 +3765,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'IndexInjection',
- declaration: 'export type IndexInjection = {\n kind: \'global\';\n name: string;\n value: unknown;\n} | {\n kind: \'script\';\n placement: IndexInjectionPlacement;\n text: string;\n} | {\n kind: \'script-src\';\n placement: IndexInjectionPlacement;\n src: string;\n} | {\n kind: \'style\';\n text: string;\n} | {\n kind: \'html\';\n placement: IndexInjectionPlacement;\n html: string;\n};',
+ declaration: 'export type IndexInjection = {\n kind: \'global\';\n name: string;\n value: unknown;\n} | {\n kind: \'script\';\n placement: IndexInjectionPlacement;\n text: string;\n} | {\n kind: \'script-src\';\n placement: IndexInjectionPlacement;\n src: string;\n} | {\n kind: \'script-preload\';\n src: string;\n} | {\n kind: \'style\';\n text: string;\n} | {\n kind: \'html\';\n placement: IndexInjectionPlacement;\n html: string;\n};',
},
{
name: 'IndexInjectionPlacement',
diff --git a/packages/host/webserver/README.i18n.yaml b/packages/host/webserver/README.i18n.yaml
index aae84858b2..8a6b93bb12 100644
--- a/packages/host/webserver/README.i18n.yaml
+++ b/packages/host/webserver/README.i18n.yaml
@@ -2,5 +2,5 @@
# 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 packages/host/webserver/README.md
-README.md: 0dc8f197f923c2dc4cb2d72ccb5b3a31f5384503
-README.zh.md: d19e4a6be1df0c464d7ac61726e6bfb45a92c8a1
+README.md: c6abc503222fc8bf60d4b6c940eeb1f7910cc9aa
+README.zh.md: 430488869c98a86ff669e12acfaee86bae7aa8a3
diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md
index 0dc8f197f9..c6abc50322 100644
--- a/packages/host/webserver/README.md
+++ b/packages/host/webserver/README.md
@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
-Web HTTP and upgrade-route registration plugin (default-exported `WebServer`, config `{host, port}`): a `node:http` server that listens on activation and provides `ctx.webServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `registerFallback(handler)` registers the one handler for requests that match no named route. A second registration throws; the SPA dist server [`dsh-host-frontend-static`](../frontend-static/README.md) is the shipped owner, and the server returns 404 while none is registered. Index startup inputs are structured rows: `collectIndexInjections()` gathers a fresh `IndexInjection` table over one `webserver/index-inject` emit per call, and `renderIndex(html)` renders the rows into an index.html body before applying the raw `tapIndex(transform)` transforms in registration order (`applyIndexTaps(html)`, the escape hatch for markup no row expresses); the fallback handler calls `renderIndex` on every index response, and a static deployment ships the same rows over its boot payload, rendering with the exported `renderIndexInjections`. `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP match order is fixed: exact over the whole table, then longest prefix, then the fallback handler. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics.
+Web HTTP and upgrade-route registration plugin (default-exported `WebServer`, config `{host, port}`): a `node:http` server that listens on activation and provides `ctx.webServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `registerFallback(handler)` registers the one handler for requests that match no named route. A second registration throws; the SPA dist server [`dsh-host-frontend-static`](../frontend-static/README.md) is the shipped owner, and the server returns 404 while none is registered. Index startup inputs are structured rows: `collectIndexInjections()` gathers a fresh `IndexInjection` table over one `webserver/index-inject` emit per call, and `renderIndex(html)` renders the rows into an index.html body before applying the raw `tapIndex(transform)` transforms in registration order (`applyIndexTaps(html)`, the escape hatch for markup no row expresses); `script-preload` rows render advisory classic-script preload links. The fallback handler calls `renderIndex` on every index response, and a static deployment ships the same rows over its boot payload. `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP match order is fixed: exact over the whole table, then longest prefix, then the fallback handler. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics.
The package knows no harness concepts and serves no files: the `/api` HTTP bridge and downlink WebSockets are routes owned by the connection plugin, plugin bundles and the HMR event stream are routes owned by the modules/hmr plugins, and dist serving belongs to the fallback owner. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure). This server serves browsers only; Electron loads dist over `file://` and carries fetch over an IPC bridge. This package never prints; the URL line belongs to the shell.
diff --git a/packages/host/webserver/README.zh.md b/packages/host/webserver/README.zh.md
index d19e4a6be1..430488869c 100644
--- a/packages/host/webserver/README.zh.md
+++ b/packages/host/webserver/README.zh.md
@@ -2,7 +2,7 @@
[English](README.md) | 中文
-Web HTTP 与 upgrade route 注册插件(默认导出 `WebServer`,配置为 `{host, port}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.webServer`。`register(route)` 添加具名的 `exact`/`prefix` HTTP route;`registerUpgrade(route)` 添加精确 pathname 的 upgrade route;同一张表内的重复路径会抛错,因为 route 模式是组合层约定,冲突即配置错误;两者返回的 disposer 都会移除注册。`registerFallback(handler)` 注册一个 handler,处理所有未被具名 route 命中的请求。第二次注册会抛错;随附的 SPA dist 服务器 [`dsh-host-frontend-static`](../frontend-static/README.zh.md) 是该 handler 的所有者,没有注册 handler 时服务器返回 404。index 的启动输入是结构化行:`collectIndexInjections()` 每次调用经一次 `webserver/index-inject` emit 现收一张全新的 `IndexInjection` 表,`renderIndex(html)` 先把行渲染进 index.html 响应体,再按注册顺序应用原始的 `tapIndex(transform)` 转换(`applyIndexTaps(html)`,行无法表达的标记的逃生口);fallback handler 在每次 index 响应时调用 `renderIndex`,静态部署则把同一批行经 boot 载荷下发,用导出的 `renderIndexInjections` 渲染。`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后交给 fallback handler。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不影响请求处理。
+Web HTTP 与 upgrade route 注册插件(默认导出 `WebServer`,配置为 `{host, port}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.webServer`。`register(route)` 添加具名的 `exact`/`prefix` HTTP route;`registerUpgrade(route)` 添加精确 pathname 的 upgrade route;同一张表内的重复路径会抛错,因为 route 模式是组合层约定,冲突即配置错误;两者返回的 disposer 都会移除注册。`registerFallback(handler)` 注册一个 handler,处理所有未被具名 route 命中的请求。第二次注册会抛错;随附的 SPA dist 服务器 [`dsh-host-frontend-static`](../frontend-static/README.zh.md) 是该 handler 的所有者,没有注册 handler 时服务器返回 404。index 的启动输入是结构化行:`collectIndexInjections()` 每次调用经一次 `webserver/index-inject` emit 现收一张全新的 `IndexInjection` 表,`renderIndex(html)` 先把行渲染进 index.html 响应体,再按注册顺序应用原始的 `tapIndex(transform)` 转换(`applyIndexTaps(html)`,行无法表达的标记的逃生口);`script-preload` 行渲染为 classic script 的提示性预加载链接。fallback handler 在每次 index 响应时调用 `renderIndex`,静态部署则把同一批行经 boot 载荷下发。`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后交给 fallback handler。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不影响请求处理。
该包不了解任何 harness 概念,也不提供任何文件服务:`/api` HTTP 桥接与下行 WebSocket 是 connection 插件的 route,插件 bundle 与 HMR(热模块替换)事件流是 modules/hmr 插件的 route,dist 服务则属于 fallback 持有者。upgrade handler 拥有协议握手与连接内容;webserver 只交付原始 socket 与 request。`host` 只接受 `127.0.0.1`(默认安全姿态)和 `0.0.0.0`(有意向网络开放)。该服务器只服务浏览器;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch。该包从不打印内容;URL 行属于 shell。
diff --git a/packages/host/webserver/src/injections.ts b/packages/host/webserver/src/injections.ts
index 7a61ae0510..5b431918f5 100644
--- a/packages/host/webserver/src/injections.ts
+++ b/packages/host/webserver/src/injections.ts
@@ -23,6 +23,8 @@ export type IndexInjection =
* loader resolves worker-only URLs such as `/plugins/...`).
*/
| { kind: 'script-src'; placement: IndexInjectionPlacement; src: string }
+ /** Advisory preload for an external classic script; static workers may ignore it. */
+ | { kind: 'script-preload'; src: string }
/** A `${row.text}` }
case 'script-src':
return { placement: row.placement, markup: `` }
+ case 'script-preload':
+ return { placement: 'head', markup: `` }
case 'style':
return { placement: 'head', markup: `` }
case 'html':
diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts
index e8fa315ecc..198716d778 100644
--- a/packages/host/webserver/tests/webserver.spec.ts
+++ b/packages/host/webserver/tests/webserver.spec.ts
@@ -208,6 +208,7 @@ describe('real Loader composition', () => {
table.push(
{ kind: 'script', placement: 'head', text: 'window.__Q__=1' },
{ kind: 'script-src', placement: 'head', src: '/plugins/a.js?rev="1"&x=' },
+ { kind: 'script-preload', src: '/plugins/b.js?rev="2"&x=' },
{ kind: 'global', name: '__DSH_BOOT__', value: { rev: '' } },
{ kind: 'style', text: 'body{margin:0}' },
{ kind: 'html', placement: 'head', html: '' },
@@ -222,6 +223,7 @@ describe('real Loader composition', () => {
'',
'',
'',
+ '',
'globalThis["__DSH_BOOT__"] = {"rev":"\\u003c/script>\\u003cb>"}',
'',
'',