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 `` } 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>"}', '', '',