mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
Merge remote-tracking branch 'origin/master' into xtr/projection-per-session-cache
This commit is contained in:
+2
-2
@@ -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-07-24-web-config-tree-boot-and-transport-layering.md
|
||||
2026-07-24-web-config-tree-boot-and-transport-layering.md: f397ca6a25cffa0ba709d0a8e84717a1c228b951
|
||||
2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: e1cde78bad16d618913e52894eff1271dac74be1
|
||||
2026-07-24-web-config-tree-boot-and-transport-layering.md: eb30ba84ef293a169931ef6519a9d6d2ea98af7f
|
||||
2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 7a175b44fa8806b4b73eca538f32f16aa778fdee
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)
|
||||
|
||||
**Config sources have one declaration place each.** Bundle yml values are engineering defaults, Settings sections are writable user preferences, CLI flags address their owning launcher rows, and env values enter through yml `!!js` expressions. Patches replace a row's config wholesale. The resolved frontend `distIndex` uses that patch channel as an assembly fact. The transport-independent provider/model default belongs to `ctx.agentDefaultModel`; the [direct headless entry point](2026-08-09-headless-direct-core-entry-point.md) and the Web gateway consume the same state.
|
||||
|
||||
**The transport splits five ways.** `dsh-host-apiproxy` is the gateway plugin (`api-gateway` row): it default-exports `ApiProxyService`, configures only `{nativeOpen?}`, consumes the base layer's entry-point-neutral `ctx.agentDefaultModel`, provides `ctx.apiProxy`, remains transport-agnostic, and registers no routes. `dsh-host-webserver` is a plain route-registration plugin: `WebServer` provides `ctx.webServer` (`register(route) → disposer` with duplicate-pattern throw, `tapIndex` transforms applied in registration order, `port`), listens on activation, answers per-request failures with 400 and logging, and knows no harness concepts. The connection node half owns the `/api` binding from `ctx.apiProxy` through `toFetchHandler`. The modules node half (`ClientModuleRegistry`, providing `ctx.clientModules`) owns incremental package scanning, the bundle route, the index tap, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload through `fs.watchFile` membership and the `/plugins/events` SSE route.
|
||||
**The transport splits five ways.** `dsh-host-apiproxy` is the gateway plugin (`api-gateway` row): it default-exports `ApiProxyService`, configures only `{nativeOpen?}`, consumes the base layer's entry-point-neutral `ctx.agentDefaultModel`, provides `ctx.apiProxy`, remains transport-agnostic, and registers no routes. `dsh-host-webserver` is a plain route-registration plugin: `WebServer` provides `ctx.webServer` (`register(route) → disposer` with duplicate-pattern throw, `renderIndex` rendering — structured `webserver/index-inject` rows, then raw `tapIndex` transforms in registration order — and `port`), listens on activation, answers per-request failures with 400 and logging, and knows no harness concepts. The connection node half owns the `/api` binding from `ctx.apiProxy` through `toFetchHandler`. The modules node half (`ClientModuleRegistry`, providing `ctx.clientModules`) owns incremental package scanning, the bundle route, the boot injection rows, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload through `fs.watchFile` membership and the `/plugins/events` SSE route.
|
||||
|
||||
**Package export discipline.** The modules package exposes exactly `.` (node half) and `./client` (the complete browser half: `ClientModuleSystem`, `parseBootManifest`, the adoption plugin face) — no bespoke subpaths; wire types re-export through the root for host-side consumers. The adoption handshake: the kernel writes the constructed instance to `window.__DSH_MODULES__` before cordis exists; the `./client` apply reads the slot (missing = loud throw) and provides `ctx.modules`.
|
||||
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ Status: implemented
|
||||
|
||||
**每个配置源有唯一声明位置。** 组合包 yml 值是工程默认,Settings 分节是可写的用户偏好,CLI(命令行界面)flags 面向其归属的启动器配置行,env 值则通过 yml `!!js` 表达式进入。patch 会整体替换一行的 config。解析后的前端 `distIndex` 通过同一条 patch 通道作为组装事实传递。与传输无关的提供方/模型默认值归 `ctx.agentDefaultModel` 所有;[直接 headless 入口](2026-08-09-headless-direct-core-entry-point.md)与 Web 网关消费同一份状态。
|
||||
|
||||
**传输五分。** `dsh-host-apiproxy` 是网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,只配置 `{nativeOpen?}`,消费 base 层不偏向特定入口的 `ctx.agentDefaultModel`,provide `ctx.apiProxy`,保持传输无关且不注册路由。`dsh-host-webserver` 是朴素的路由注册插件:`WebServer` provide `ctx.webServer`(`register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen,单请求失败时答 400 并记日志,且不认识任何 harness 概念。connection node 半拥有从 `ctx.apiProxy` 经 `toFetchHandler` 绑定到 `/api` 的逻辑。modules node 半(`ClientModuleRegistry`,provide `ctx.clientModules`)拥有单包增量扫描、bundle 路由、index tap 与 `onRebuilt`/`onGraphChanged` 通知。HMR(热模块替换) node 半通过 `fs.watchFile` membership 与 `/plugins/events` SSE 路由拥有开发期重载。
|
||||
**传输五分。** `dsh-host-apiproxy` 是网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,只配置 `{nativeOpen?}`,消费 base 层不偏向特定入口的 `ctx.agentDefaultModel`,provide `ctx.apiProxy`,保持传输无关且不注册路由。`dsh-host-webserver` 是朴素的路由注册插件:`WebServer` provide `ctx.webServer`(`register(route) → disposer`、重复 pattern 即抛、`renderIndex` 渲染——先结构化 `webserver/index-inject` 行、后原始 `tapIndex` 按注册序应用——与 `port`),激活即 listen,单请求失败时答 400 并记日志,且不认识任何 harness 概念。connection node 半拥有从 `ctx.apiProxy` 经 `toFetchHandler` 绑定到 `/api` 的逻辑。modules node 半(`ClientModuleRegistry`,provide `ctx.clientModules`)拥有单包增量扫描、bundle 路由、启动注入行与 `onRebuilt`/`onGraphChanged` 通知。HMR(热模块替换) node 半通过 `fs.watchFile` membership 与 `/plugins/events` SSE 路由拥有开发期重载。
|
||||
|
||||
**包出口纪律。** modules 包只暴露 `.`(node 半)与 `./client`(完整浏览器半:`ClientModuleSystem`、`parseBootManifest`、收编插件面)——不设专用子路径;wire 类型经根出口 re-export 给 host 侧消费方。收编握手:内核在 cordis 之前把建好的实例写入 `window.__DSH_MODULES__`;`./client` 的 apply 读取该槽位(缺少时显式抛错)并 provide `ctx.modules`。
|
||||
|
||||
|
||||
@@ -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-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
|
||||
@@ -0,0 +1,30 @@
|
||||
# Agent Note: structured index injection table (webserver/index-inject)
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-19-web-index-injection-table.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The web shell's boot HTML needs three kinds of injection: client-modules' boot protocol (the `__ModuleLoader__` registration queue inline script, the parser-blocking preload `<script src>` tags, the `__DSH_BOOT__` graph global) and ui-theme's first-paint theme script. The old mechanism was `webServer.tapIndex(html => html)` string transforms: each registrant regex-located `<head>`/`<body>` and spliced HTML on its own. The static worker deployment (the page is a build artifact; the host tree runs in a Web Worker) has no serve-HTML step at all, so the worker side hand-copied the same data into its `/__boot__` payload (`graph` + `theme` via `ctx.get`), and the page side re-implemented what the taps did (a facade installer, a theme applier, a preload loop) — one boot semantics, three implementations.
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
|
||||
`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.
|
||||
- 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.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep tap functions, add a worker-side renderer that re-runs them over a fake document** — rejected: taps are opaque `html => html` closures, so the worker cannot serialize or replay them without shipping a DOM emulation into the boot path.
|
||||
- **A registration-style table (`registerInjection(row): dispose`)** — rejected for the two problems the event dissolves: rows staled against live state (theme preference, module graph) unless every producer re-registered on change, and every producer owned one more disposer. The per-emit pull reads fresh state with fiber-scoped cleanup for free.
|
||||
- **Deleting `tapIndex` outright** — rejected: an escape hatch for raw HTML transforms costs nothing while the table is young, and external compositions may have transforms no row kind expresses yet.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Agent Note: 结构化 index 注入表(webserver/index-inject 事件)
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-19-web-index-injection-table.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
Web 壳的启动 HTML 需要三类注入:client-modules 的引导协议(`__ModuleLoader__` 注册队列内联脚本、parser 阻塞的 preload `<script src>`、`__DSH_BOOT__` 全局图)与 ui-theme 的首帧主题脚本。旧机制是 `webServer.tapIndex(html => html)` 字符串变换:每个注册方各自用正则找 `<head>`/`<body>` 改 HTML。静态 worker 部署(页面是构建产物、host 树在 Web Worker 里)没有「服 HTML」这一步,于是 worker 侧只能在 `/__boot__` 载荷里手工重抄同一批数据(graph + theme,经 `ctx.get` 硬掏),页面侧再用手写代码(facade 安装、theme 应用、preload 循环)把 tap 干的事重演一遍——同一份启动语义存在三份实现。
|
||||
|
||||
## Decision
|
||||
|
||||
注入面事件化、数据化:webserver 声明 `webserver/index-inject` 事件与纯数据行类型 `IndexInjection`(`global`/`script`/`script-src`/`style`/`html`,`head|body` 定位)。想注入的插件订阅事件、往表里 push 行;每次收集(`collectIndexInjections()`)都是一次全新 emit,订阅方现读现填(模块图、主题偏好天然新鲜,无重注册问题),订阅随 fiber 销毁自动摘除。
|
||||
|
||||
一张表两个渲染器:served 形态 `webServer.renderIndex(html)` 确定性把行渲染进 index.html(head 行插 head 首、body 行插 body 首,全局值 JSON `<` 转义、src 属性转义);worker 形态 `/__boot__` 载荷就是 `{ injections }`,页面侧小解释器逐行执行(设全局 / 建脚本元素 / 经 tunnel loadBundle 载外链 / 挂样式与 DOM)。行是纯 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→全局 三行有序。
|
||||
- `__DSH_BOOT__` 的 served 渲染文本从 `window.__DSH_BOOT__ =` 变为 `globalThis["__DSH_BOOT__"] =`;已核实无已提交快照期望含此文本,无需重录。
|
||||
- 新的模型可见/页面可见注入一律走行类型扩展,不再新增 tap 消费者。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **保留 tap 函数、worker 侧对假 document 重放**——否决:tap 是不透明的 `html => html` 闭包,worker 无法序列化或重放,除非把 DOM 仿真塞进启动链。
|
||||
- **注册表式(`registerInjection(row): dispose`)**——否决于事件天然化解的两个问题:行数据会相对活状态(主题偏好、模块图)过期,除非每个生产者变更时重注册;且每个生产者多背一个 disposer。按次 emit 的拉取免费获得新鲜读取与 fiber 级清理。
|
||||
- **直接删除 `tapIndex`**——否决:表还年轻,原始 HTML 变换的逃生口零成本,外部组合可能还有行类型暂不能表达的变换。
|
||||
@@ -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/bug-fix/2026-08-10-pre-plugin-theme-bootstrap.md
|
||||
2026-08-10-pre-plugin-theme-bootstrap.md: 7f5c57316d7dd022b08918459282a6f6128eb93b
|
||||
2026-08-10-pre-plugin-theme-bootstrap.zh.md: 7152bf8dc2bbfae2a671c4da899cbebda0bd4b39
|
||||
2026-08-10-pre-plugin-theme-bootstrap.md: cf3b2d5b6c8e8b102df6a37bab0fc63d81d8736b
|
||||
2026-08-10-pre-plugin-theme-bootstrap.zh.md: 2836f7b6d32528796bf64bf708229d5677866364
|
||||
|
||||
@@ -12,7 +12,7 @@ The web shell renders `Loading plugins…` before the browser-side plugin tree a
|
||||
|
||||
## Decision
|
||||
|
||||
ui-theme's host half transforms each index HTML document through `ctx.webServer.tapIndex()`, inserting a synchronous inline script immediately after the opening `<body>` tag. The transform registers under an optional `httpServer` injection, so compositions without that service still activate ui-theme and install no transform. When the HTML parser executes the script, the body exists, but the shell's module script and framework-free boot page have not yet run.
|
||||
ui-theme's host half answers every `webserver/index-inject` collection with one body-placed script row (`bootThemeInjection`), which `renderIndex` renders as a synchronous inline script immediately after the opening `<body>` tag. The subscription is unconditional — a composition without a web server never emits the event, so ui-theme still activates and contributes nothing. When the HTML parser executes the script, the body exists, but the shell's module script and framework-free boot page have not yet run.
|
||||
|
||||
The host half registers the [`ui-theme.preference` settings section](2026-08-06-host-backed-web-preferences.md) when a settings provider exists. For each index response, it embeds that schema-validated built-in preference in the inline script; without a settings provider or active registration, it embeds the `system` default. The browser resolves `system` through `prefers-color-scheme`, falling back to light when `matchMedia` is unavailable. It writes only the two pieces of DOM state that ThemePresenter later owns: `document.documentElement.style.colorScheme` and `body[data-ds-dark-theme]`.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Web 壳在浏览器侧插件树激活前呈现 `Loading plugins…`。ui-theme
|
||||
|
||||
## 决策
|
||||
|
||||
ui-theme 的主机侧通过 `ctx.webServer.tapIndex()` 转换每份 index HTML,在 `<body>` 起始标签后紧接一段同步内联脚本。该转换通过可选的 `httpServer` 注入注册,因此不含该服务的组合仍会激活 ui-theme,但不会安装转换。HTML 解析器执行该脚本时,body 已存在,而壳的模块脚本与不依赖框架的启动页尚未运行。
|
||||
ui-theme 的主机侧以一条 body 定位的 script 行(`bootThemeInjection`)回应每次 `webserver/index-inject` 收集,`renderIndex` 把它渲染为 `<body>` 起始标签后紧接的一段同步内联脚本。订阅是无条件的——没有 web server 的组合根本不会 emit 该事件,ui-theme 照常激活且不贡献任何行。HTML 解析器执行该脚本时,body 已存在,而壳的模块脚本与不依赖框架的启动页尚未运行。
|
||||
|
||||
settings provider 存在时,主机侧会注册 [`ui-theme.preference` settings 分节](2026-08-06-host-backed-web-preferences.md)。它为每份 index 响应把经过 schema 校验的内建偏好嵌入内联脚本;不存在 settings provider 或有效注册时则嵌入默认值 `system`。浏览器通过 `prefers-color-scheme` 解析 `system`,不支持 `matchMedia` 时回退为浅色。脚本只写 ThemePresenter 后续拥有的两项 DOM 状态:`document.documentElement.style.colorScheme` 与 `body[data-ds-dark-theme]`。
|
||||
|
||||
|
||||
@@ -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/feature/2026-08-20-multiline-question-answer-field.md
|
||||
2026-08-20-multiline-question-answer-field.md: 5e411d6728205ce1b72ef8355bbf2493f28873eb
|
||||
2026-08-20-multiline-question-answer-field.zh.md: 6b98f017bbee2e8d89810c5b193b3707c85d1e95
|
||||
@@ -0,0 +1,45 @@
|
||||
# Agent Note: Multi-line answers in the question composer
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-20-multiline-question-answer-field.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`ask_user_question` offers a free-text answer beside the model's own options. On a question that carried options, that answer was a single-line `<input>`: a long sentence scrolled sideways inside one 24px line, Shift+Enter did nothing, and an answer with structure — two requirements, a short list, a paragraph — could not be typed at all. The optionless question already used a textarea, but a fixed 64–140px box that neither followed the draft nor opened wider.
|
||||
|
||||
The chat composer next to it grows with the draft and takes Shift+Enter as a newline. A user who has just typed a multi-line prompt there meets a field that silently flattens the same answer.
|
||||
|
||||
## Decision
|
||||
|
||||
Both question shapes answer into one `AnswerField`: a `<textarea rows={1}>` sharing a single CSS-grid cell with a hidden mirror `<div>` that renders the draft plus a trailing newline.
|
||||
|
||||
The mirror sits in normal flow and so sizes the grid row; the textarea stretches to that row, and `rows={1}` keeps the control's own intrinsic height out of the row sizing, leaving the mirror the only input to the height. Soft wraps are invisible to a `'\n'` count, so the mirror is what makes a wrapped answer grow the box rather than scroll one line. The trailing newline covers the last line the textarea's caret can reach and the block container drops. Mirror and textarea must keep identical type, padding, and wrapping rules; a divergence sizes the box wrong for the text being typed.
|
||||
|
||||
Growth stops at the mirror's `max-height` of six lines, and past that the textarea scrolls itself. The mirror takes `box-sizing: content-box` against the card-wide `border-box` so that cap counts text lines rather than text plus padding: the optionless variant carries 16px of vertical padding, which under `border-box` spends two thirds of a line and delivers the last one as an 8px sliver, while the inline variant has no padding and would land on a different line count from the same declaration. It is the only scrollport in the stack: unlike the chat composer, this field paints its own glyphs, so there is no second layer whose scroll offset would have to match.
|
||||
|
||||
Enter continues the flow and submits the batch on the last question, Shift+Enter breaks the line, and the IME guard is unchanged — Enter during composition confirms the candidate without advancing. The `variant` prop names which of the two looks the field takes, so the field owns both and neither caller assembles one out of class names.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**`field-sizing: content`.** Rejected for the same reason [the composer's Safari recovery](../bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md) rejected it: Safari reproduces a stale intrinsic height after a deletion crosses a wrap threshold. The mirror is a plain block whose height Safari computes correctly, and it is already the technique this repository runs in the chat composer.
|
||||
|
||||
**Resize in JS on every keystroke** — set `height: auto`, read `scrollHeight`, write it back. Rejected: it pays two forced layouts per keystroke and reintroduces the stale-geometry class of defect the mirror avoids, in exchange for no capability the mirror lacks.
|
||||
|
||||
**Reuse the InputBar stack verbatim.** Rejected because that stack carries a decoration backdrop between mirror and textarea, which forces both layers into one outer scrollport so the caret and the glyphs cannot drift apart. This field has no backdrop, so letting the textarea own its own scroll removes the outer scrollport and that obligation with it.
|
||||
|
||||
**A separate expand-to-dialog entry for long answers.** Rejected as unnecessary: growing in place already satisfies the requirement, and a dialog would take the options the answer is an alternative to off screen at the moment the user is weighing them.
|
||||
|
||||
**Uncapped growth.** Rejected because the card tops out at `min(60vh, 520px)` and still owes that budget to the title, the option rows, and the footer actions; an unbounded field pushes the choices the answer belongs to out of view.
|
||||
|
||||
**A per-variant cap that absorbs each variant's padding.** Rejected because it couples the line count to a padding value: changing `.customBlock`'s inset would silently change how many lines the field grows to. `content-box` states the intent once, in the units the cap is written in.
|
||||
|
||||
## Testing
|
||||
|
||||
Component tests pin the round trip: both shapes render a textarea, the mirror follows the draft, Shift+Enter never advances the flow, and line breaks reach the answer batch verbatim. The assembled `question-composer` web e2e measures the live engine — a soft-wrapped draft grows the field without scrolling it, two Shift+Enter presses leave `"\n\n"` in a taller field with the question still open, and a draft past the cap scrolls instead of growing at exactly six text lines in both variants.
|
||||
|
||||
## Consequences
|
||||
|
||||
An answer can now carry the structure the question asks for, and the field the user sees behaves like the chat composer above it. The cost is a second element per field and the standing obligation to keep mirror and textarea metrics identical, which the JSDoc at `AnswerField` states and the e2e growth assertion detects.
|
||||
|
||||
The [single-select highlight item](https://github.com/deepseek-harness/deepseek-harness/issues/1687) of the same issue is untouched: focusing the custom field still leaves the previously chosen option visually selected until the first character lands.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Agent Note: Multi-line answers in the question composer
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-20-multiline-question-answer-field.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
`ask_user_question`在模型给出的选项旁提供一个自由文本答案。在带选项的问题上,该答案曾是单行 `<input>`:长句子在 24px 的一行内横向滚动,Shift+Enter 毫无作用,带结构的答案——两条要求、一个短列表、一段话——根本无法输入。无选项问题虽已使用 textarea,但那是固定 64–140px 的框,既不跟随草稿,也无法展开。
|
||||
|
||||
紧邻它的聊天输入框会随草稿增高,并把 Shift+Enter 当作换行。刚在那里敲完多行提示词的用户,转身遇到的却是一个会悄悄抹平同一个答案的输入框。
|
||||
|
||||
## Decision
|
||||
|
||||
两种问题形状都写入同一个 `AnswerField`:一个 `<textarea rows={1}>`,与一个渲染「草稿 + 结尾换行」的隐藏镜像 `<div>` 共享同一个 CSS grid 单元格。
|
||||
|
||||
镜像位于常规流中,因而决定 grid 行的高度;textarea 拉伸到该行,而 `rows={1}` 使控件自身的固有高度不参与行尺寸计算,于是镜像是高度的唯一输入。软换行对 `'\n'` 计数不可见,因此正是镜像让换行后的答案把输入框撑高,而不是在一行内滚动。结尾的换行补上 textarea 光标可达、而块级容器会丢弃的最后一行。镜像与 textarea 必须保持完全一致的字体、内边距与换行规则;一旦偏离,输入框的高度就与正在输入的文本不符。
|
||||
|
||||
增高在镜像 `max-height` 的六行处停止,此后由 textarea 自身滚动。镜像用 `box-sizing: content-box` 覆盖卡片范围内的 `border-box`,使该上限计量的是文本行而不是「文本加内边距」:无选项变体带 16px 纵向内边距,在 `border-box` 下这会吃掉三分之二行、并把最后一行变成 8px 残条,而 inline 变体没有内边距,同一条声明会落到不同的行数上。它是这个结构里唯一的滚动容器:与聊天输入框不同,本输入框自己绘制字形,因此不存在第二个需要对齐滚动偏移的图层。
|
||||
|
||||
Enter 继续流程并在最后一题提交整批,Shift+Enter 换行,IME 保护不变——组合输入期间按 Enter 只确认候选,不前进。`variant` 属性指明该输入框采用两种外观中的哪一种,因此外观归输入框自己所有,任何调用方都不必用类名拼装。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**`field-sizing: content`。** 与[聊天输入框的 Safari 恢复方案](../bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md)出于同一理由否决:当删除跨过换行阈值后,Safari 会复现陈旧的固有高度。镜像是一个 Safari 能正确计算高度的普通块,而且它已经是本仓库在聊天输入框中运行的技术。
|
||||
|
||||
**在 JS 中逐次按键重算高度**——置 `height: auto`、读 `scrollHeight`、再写回。否决:每次按键要付两次强制布局,并重新引入镜像所避开的那类陈旧几何缺陷,换来的却是镜像本就具备的能力。
|
||||
|
||||
**原样复用 InputBar 的图层结构。** 否决,因为该结构在镜像与 textarea 之间还有一层装饰背板,这迫使两个图层共用一个外层滚动容器,以免光标与字形彼此漂移。本输入框没有背板,因此让 textarea 自己滚动,既去掉了外层滚动容器,也一并去掉了那项对齐义务。
|
||||
|
||||
**为长答案单独提供展开为对话框的入口。** 作为多余方案否决:就地增高已满足需求,而对话框会在用户正权衡选项时,把答案所要替代的那些选项挤出屏幕。
|
||||
|
||||
**不设增高上限。** 否决,因为卡片上限为 `min(60vh, 520px)`,还要把这份额度分给标题、选项行与底部操作;不设上限的输入框会把答案所属的选项挤出视野。
|
||||
|
||||
**按变体各自吸收内边距的上限。** 否决,因为那会把行数与某个内边距值耦合:改动 `.customBlock` 的内边距会悄悄改变输入框能长到几行。`content-box` 只需声明一次意图,且与上限本身的单位一致。
|
||||
|
||||
## Testing
|
||||
|
||||
组件测试固定了整条往返:两种形状都渲染 textarea、镜像跟随草稿、Shift+Enter 绝不前进流程、换行原样进入答案批次。组装后的 `question-composer` web e2e 则在真实引擎上测量——软换行的草稿把输入框撑高而不滚动,两次 Shift+Enter 之后 `"\n\n"` 留在更高的输入框里且问题仍未关闭,超过上限的草稿在两种变体下都恰好于六行文本处改为滚动而不再增高。
|
||||
|
||||
## Consequences
|
||||
|
||||
答案现在能承载问题所要求的结构,用户看到的输入框与其上方的聊天输入框行为一致。代价是每个输入框多一个元素,以及保持镜像与 textarea 度量完全一致的长期义务——`AnswerField` 的 JSDoc 陈述了这项义务,e2e 的增高断言负责发现违反。
|
||||
|
||||
同一 Issue 中的[单选高亮项](https://github.com/deepseek-harness/deepseek-harness/issues/1687)未被触及:聚焦自定义输入框时,先前选中的选项在第一个字符落下之前仍保持视觉选中。
|
||||
@@ -13,7 +13,7 @@ import { dirname, join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { act, cleanup } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, vi } from 'vitest'
|
||||
import { injectBootManifest, orderByModuleGraph } from '@deepseek-ai/dsh-client-modules'
|
||||
import { bootInjections, orderByModuleGraph } from '@deepseek-ai/dsh-client-modules'
|
||||
import type { ClientModuleLoaderTarget, WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
|
||||
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
|
||||
|
||||
@@ -194,10 +194,9 @@ export function mountAssembledApp(search = '?fixture'): void {
|
||||
root.id = 'root'
|
||||
document.body.appendChild(root)
|
||||
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ bundlePath: _bundlePath, ...plugin }) => plugin) }
|
||||
const html = injectBootManifest('<head></head>', win.__DSH_BOOT__)
|
||||
const facadeSource = /<head><script>([\s\S]*?)<\/script>/.exec(html)?.[1]
|
||||
if (facadeSource === undefined) throw new Error('missing injected ModuleLoader facade')
|
||||
;(0, eval)(facadeSource)
|
||||
const [facadeRow] = bootInjections(win.__DSH_BOOT__)
|
||||
if (facadeRow?.kind !== 'script') throw new Error('missing injected ModuleLoader facade row')
|
||||
;(0, eval)(facadeRow.text)
|
||||
// Mirror the blocking Host-injected scripts before the Vite entry calls create().
|
||||
for (const id of ['@deepseek-ai/dsh-client-modules', '@deepseek-ai/dsh-client-runtime']) {
|
||||
const plugin = PLUGINS.find(candidate => candidate.id === id)
|
||||
|
||||
@@ -10,10 +10,11 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import type { Browser, Locator, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
@@ -30,6 +31,31 @@ const COMPOSED_EXPECTED = join(SNAPSHOT_DIR, 'composed.expected.md')
|
||||
const ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'answered.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
// The composer's own growth cap, in text lines (QuestionComposer.module.css
|
||||
// .fieldMirror). Asserted as TEXT lines, not as a box height: the two variants
|
||||
// carry different padding, and a cap measured in border-box pixels silently
|
||||
// means a different line count in each — which is exactly how the optionless
|
||||
// field came to stop two thirds of a line short.
|
||||
const CAP_LINES = 6
|
||||
|
||||
/**
|
||||
* Measure a saturated answer field: how many whole text lines it grew to, and
|
||||
* whether it took over the scrolling once it stopped growing.
|
||||
* @param field - the composer's custom-answer textarea.
|
||||
* @returns whole text lines the content box holds, and whether the field scrolls.
|
||||
*/
|
||||
async function capMetrics(field: Locator): Promise<{ textLines: number; scrolls: boolean }> {
|
||||
await field.fill('x\n'.repeat(40))
|
||||
return field.evaluate((el: HTMLTextAreaElement) => {
|
||||
const style = getComputedStyle(el)
|
||||
const text = el.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom)
|
||||
return {
|
||||
textLines: Math.round(text / parseFloat(style.lineHeight)),
|
||||
scrolls: el.scrollHeight > el.clientHeight,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The options carry long descriptions on purpose: the squeeze assertion below
|
||||
// needs option copy that WRAPS, which is the only text layout that reproduces a
|
||||
// collapsed row painting its copy outside its own box.
|
||||
@@ -41,6 +67,7 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
let answeredSession: SessionId | undefined
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 })
|
||||
@@ -132,9 +159,38 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
await page.setViewportSize(original)
|
||||
}
|
||||
|
||||
// Multi-line custom answer: the field is a textarea whose hidden mirror
|
||||
// owns the box height, so a soft-wrapped or line-broken draft GROWS the
|
||||
// field instead of scrolling one line, and Shift+Enter breaks the line
|
||||
// rather than continuing the flow. Measured on the live composer because
|
||||
// only a real engine soft-wraps; growth stops at the mirror's cap, past
|
||||
// which the textarea is the one thing that scrolls. Replay only, same as
|
||||
// the squeeze above: record mode must reach the recording write.
|
||||
const custom = composer.getByRole('textbox')
|
||||
if (MODE !== 'record') {
|
||||
const oneLineHeight = await custom.evaluate(el => el.getBoundingClientRect().height)
|
||||
await custom.fill('a'.repeat(120))
|
||||
const wrapped = await custom.evaluate(el => ({
|
||||
height: el.getBoundingClientRect().height,
|
||||
scrolls: el.scrollHeight > el.clientHeight,
|
||||
}))
|
||||
expect(wrapped.height).toBeGreaterThan(oneLineHeight * 1.5)
|
||||
expect(wrapped.scrolls).toBe(false)
|
||||
|
||||
await custom.fill('')
|
||||
await custom.press('Shift+Enter')
|
||||
await custom.press('Shift+Enter')
|
||||
expect(await custom.inputValue()).toBe('\n\n')
|
||||
expect(await composer.getByText('Which color do you prefer?').count()).toBeGreaterThan(0)
|
||||
expect(await custom.evaluate(el => el.getBoundingClientRect().height))
|
||||
.toBeGreaterThan(oneLineHeight * 2.5)
|
||||
|
||||
expect(await capMetrics(custom)).toEqual({ textLines: CAP_LINES, scrolls: true })
|
||||
await custom.fill('')
|
||||
}
|
||||
|
||||
const blue = composer.getByRole('checkbox', { name: 'Blue' })
|
||||
await blue.click()
|
||||
const custom = composer.getByRole('textbox')
|
||||
await custom.fill('Include accessibility notes')
|
||||
expect(await blue.getAttribute('aria-checked')).toBe('true')
|
||||
expect(await custom.inputValue()).toBe('Include accessibility notes')
|
||||
@@ -149,6 +205,7 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
await recordFixture(scaffold, sessionId, FIXTURE)
|
||||
return
|
||||
}
|
||||
answeredSession = sessionId
|
||||
// World state: the tool result carries the chosen answer, and DONE lands.
|
||||
const results = sessionEvents.filter(e => e.type === 'tool/result')
|
||||
const answerText = results.flatMap(event => event.data.message.content.flatMap(block =>
|
||||
@@ -172,6 +229,47 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 200_000)
|
||||
|
||||
// The fixture's question carries options, so the round trip above only ever
|
||||
// exercises the inline shape. The optionless shape is the one that carries
|
||||
// padding, which is where a cap measured in box pixels drifts off the line
|
||||
// count — so it is asked straight through the user-questions seam (the same
|
||||
// service the tool calls; no model round is involved in a layout metric).
|
||||
it.skipIf(MODE === 'record')('grows the optionless answer to the same cap', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-question-optionless'))
|
||||
const sessionId = answeredSession
|
||||
expect(sessionId).toBeDefined()
|
||||
const agent = scaffold.ctx.agents.get(sessionId as SessionId)
|
||||
expect(agent).toBeDefined()
|
||||
const asked = scaffold.ctx.userQuestions.ask({
|
||||
agent: agent as NonNullable<typeof agent>,
|
||||
questions: [{ id: 'free', header: 'More', question: 'Anything else?' }],
|
||||
})
|
||||
|
||||
const composer = page.locator('[data-question-key]')
|
||||
await composer.waitFor({ timeout: 30_000 })
|
||||
const field = composer.getByRole('textbox')
|
||||
// The empty field reserves its two lines AND the textarea fills that frame:
|
||||
// a reserved box the control does not fill leaves a strip that looks like
|
||||
// the field but takes no click.
|
||||
expect(await field.evaluate((el) => {
|
||||
const frame = el.parentElement as HTMLElement
|
||||
const style = getComputedStyle(frame)
|
||||
const inner = frame.getBoundingClientRect().height
|
||||
- parseFloat(style.borderTopWidth) - parseFloat(style.borderBottomWidth)
|
||||
return {
|
||||
reserved: Math.round(frame.getBoundingClientRect().height),
|
||||
fills: Math.abs(el.getBoundingClientRect().height - inner) < 0.5,
|
||||
}
|
||||
})).toEqual({ reserved: 64, fills: true })
|
||||
// The same cap the inline shape stops at — the assertion a border-box cap fails.
|
||||
expect(await capMetrics(field)).toEqual({ textLines: CAP_LINES, scrolls: true })
|
||||
|
||||
// Settle the wait so teardown is not racing a pending question.
|
||||
await composer.getByRole('button', { name: 'Skip this question' }).click()
|
||||
expect(await asked).toEqual({ answers: [{ id: 'free', selected: [] }] })
|
||||
await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0)
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||
'session.jsonl',
|
||||
|
||||
@@ -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 docs/config-catalog.md
|
||||
config-catalog.md: ff24e4ed31ba715e700c4720c7edad16bb46dc43
|
||||
config-catalog.zh.md: 61025580c4283b5271de952a6275ba1bc905d4ee
|
||||
config-catalog.md: 091009dddf5609c21cfb7542254e35e66beddd36
|
||||
config-catalog.zh.md: 3d775a3e6341931c6ddb7cbced436fde3d48ee0b
|
||||
|
||||
@@ -863,7 +863,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/host/webserver/src/index.ts:45`](../packages/host/webserver/src/index.ts)
|
||||
Source: [`packages/host/webserver/src/index.ts:59`](../packages/host/webserver/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-invariants"></a>
|
||||
|
||||
|
||||
@@ -865,7 +865,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
来源:[`packages/host/webserver/src/index.ts:45`](../packages/host/webserver/src/index.ts)
|
||||
来源:[`packages/host/webserver/src/index.ts:59`](../packages/host/webserver/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-invariants"></a>
|
||||
|
||||
|
||||
@@ -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 docs/event-producer-consumer.md
|
||||
event-producer-consumer.md: d75d64b58b041c117bf2eaa1612c1b4683378a00
|
||||
event-producer-consumer.zh.md: adfc11782e179077b17199fc4ea6e8f52b3c465b
|
||||
event-producer-consumer.md: d3901b1ddc7d99d4960324ab7459388fa4612bbe
|
||||
event-producer-consumer.zh.md: 346a293a226719f781a9587eb0adefcf3f6bed08
|
||||
|
||||
@@ -57,6 +57,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
|
||||
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) |
|
||||
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:197`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
|
||||
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | - |
|
||||
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
|
||||
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
|
||||
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
|
||||
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) |
|
||||
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:197`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
|
||||
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | - |
|
||||
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
|
||||
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
|
||||
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
|
||||
|
||||
@@ -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 docs/subsystems/client-modules.md
|
||||
client-modules.md: 52fa4ef73766e8e7e7601e604f78aded9940328f
|
||||
client-modules.zh.md: e830349be77000759684a76b2834478a803e3e11
|
||||
client-modules.md: d939cbbafa186b82d9110a6ccc41e9892b53eb95
|
||||
client-modules.zh.md: 8f407de0bd44ea2cc95b6d29ca7e149648785c0f
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
English | [中文](client-modules.zh.md)
|
||||
|
||||
The web plugin table: the Node half of the client module system in [dsh-client-modules](../../packages/client/modules), provided as `ctx.clientModules` (`ClientModuleRegistry`). It scans the host Loader's entries for packages declaring `dsh.client`, composes the `window.__DSH_BOOT__` entry graph, serves each bundle at `/plugins/<id>/client.js`, and taps the index render to inject the boot manifest — the four faces of one service. It is an optional capability of the web GUI stack, not part of the agent-loop spine, and it is a consumer of [dsh-host-webserver](../../packages/host/webserver): the carrier described in [web-server.md](web-server.md) supplies the prefix route and index tap this service registers. The same package's browser half (`ctx.modules`, the lazy-CJS module table that fetches and materializes these bundles) is kernel machinery documented in the [package README](../../packages/client/modules/README.md), not here.
|
||||
The web plugin table: the Node half of the client module system in [dsh-client-modules](../../packages/client/modules), provided as `ctx.clientModules` (`ClientModuleRegistry`). It scans the host Loader's entries for packages declaring `dsh.client`, composes the `window.__DSH_BOOT__` entry graph, serves each bundle at `/plugins/<id>/client.js`, and answers every index-injection collection with the boot manifest rows — the four faces of one service. It is an optional capability of the web GUI stack, not part of the agent-loop spine, and it is a consumer of [dsh-host-webserver](../../packages/host/webserver): the carrier described in [web-server.md](web-server.md) supplies the prefix route and the `webserver/index-inject` event this service answers. The same package's browser half (`ctx.modules`, the lazy-CJS module table that fetches and materializes these bundles) is kernel machinery documented in the [package README](../../packages/client/modules/README.md), not here.
|
||||
|
||||
Source: [`packages/client/modules/src/client/manifest.ts`](../../packages/client/modules/src/client/manifest.ts)
|
||||
|
||||
## The wire
|
||||
|
||||
The graph is the wire single source between the Node and browser halves: the host composes `WebBootEntry` rows from scanned packages, injects the graph as the first script in `<head>` (`window.__DSH_BOOT__`, with `<` escaped so plugin-controlled strings cannot break out of the script element), and the shell parses it before booting anything. A page without a valid manifest cannot boot — the browser-side parser throws loud on a missing or malformed graph.
|
||||
The graph is the wire single source between the Node and browser halves: the host composes `WebBootEntry` rows from scanned packages, publishes the graph as a `global` injection row rendered ahead of later script rows (`globalThis["__DSH_BOOT__"]`, with `<` escaped so plugin-controlled strings cannot break out of the script element), and the shell parses it before booting anything. A page without a valid manifest cannot boot — the browser-side parser throws loud on a missing or malformed graph.
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
@@ -60,9 +60,9 @@ Scanning is incremental per package; there is no full-rescan code path. Every co
|
||||
|
||||
Package metadata — including the negative "not a client package" verdict — is cached per name and never expires: plugin-set changes take effect on restart. A fiber restart reuses its row and rev untouched; bundle content changes reach the graph only through `rebuilt()`.
|
||||
|
||||
## The bundle route and index tap
|
||||
## The bundle route and index injection
|
||||
|
||||
`GET`/`HEAD /plugins/<id>/client.js` serves the registered bundle from disk with `no-cache` (the rev query, not HTTP caching, anchors consistency); other methods are 405. An unknown id — or a registered row whose bundle is unreadable because it has not been built yet — answers a loud 404 rather than letting the carrier's SPA fallback ship HTML as JavaScript. The index tap injects the current graph on every index render, so a reload always boots against the live composition.
|
||||
`GET`/`HEAD /plugins/<id>/client.js` serves the registered bundle from disk with `no-cache` (the rev query, not HTTP caching, anchors consistency); other methods are 405. An unknown id — or a registered row whose bundle is unreadable because it has not been built yet — answers a loud 404 rather than letting the carrier's SPA fallback ship HTML as JavaScript. The injection rows carry the current graph on every index render, so a reload always boots against the live composition.
|
||||
|
||||
## The service
|
||||
|
||||
@@ -82,7 +82,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
|
||||
|
||||
### `ctx.clientModules` — `ClientModuleRegistry`
|
||||
|
||||
The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index tap. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).
|
||||
The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index injection rows. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
@@ -122,5 +122,5 @@ onRebuilt(listener: (id: string, rev: string) => void): () => void
|
||||
onGraphChanged(listener: () => void): () => void
|
||||
```
|
||||
|
||||
Source: [`packages/client/modules/src/index.ts:295`](../../packages/client/modules/src/index.ts)
|
||||
Source: [`packages/client/modules/src/index.ts:282`](../../packages/client/modules/src/index.ts)
|
||||
<!-- END GENERATED cordis-surface -->
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
[English](client-modules.md) | 中文
|
||||
|
||||
Web 插件表:[dsh-client-modules](../../packages/client/modules) 中 client 模块系统的 Node 半,以 `ctx.clientModules`(`ClientModuleRegistry`)形式提供。它扫描宿主 Loader 的 entry,找出声明了 `dsh.client` 的包,组合出 `window.__DSH_BOOT__` entry 图,在 `/plugins/<id>/client.js` 提供各个 bundle,并经 index 转换(index tap)注入启动 manifest(元数据清单)——这是同一个服务的四个面。它是 Web GUI 栈的一项可选能力,不属于 agent loop(智能体循环)主干,并且是 [dsh-host-webserver](../../packages/host/webserver) 的消费方:[web-server.md](web-server.md) 所述的载体提供本服务注册的前缀路由与 index 转换。同一个包的浏览器半(`ctx.modules`,即拉取并物化这些 bundle 的 lazy CJS 模块表)属于内核机件,记录在[包 README](../../packages/client/modules/README.md)中,不在本页。
|
||||
Web 插件表:[dsh-client-modules](../../packages/client/modules) 中 client 模块系统的 Node 半,以 `ctx.clientModules`(`ClientModuleRegistry`)形式提供。它扫描宿主 Loader 的 entry,找出声明了 `dsh.client` 的包,组合出 `window.__DSH_BOOT__` entry 图,在 `/plugins/<id>/client.js` 提供各个 bundle,并以启动 manifest(元数据清单)行回应每次 index 注入收集——这是同一个服务的四个面。它是 Web GUI 栈的一项可选能力,不属于 agent loop(智能体循环)主干,并且是 [dsh-host-webserver](../../packages/host/webserver) 的消费方:[web-server.md](web-server.md) 所述的载体提供本服务注册的前缀路由与其回应的 `webserver/index-inject` 事件。同一个包的浏览器半(`ctx.modules`,即拉取并物化这些 bundle 的 lazy CJS 模块表)属于内核机件,记录在[包 README](../../packages/client/modules/README.md)中,不在本页。
|
||||
|
||||
源码:[`packages/client/modules/src/client/manifest.ts`](../../packages/client/modules/src/client/manifest.ts)
|
||||
|
||||
## wire
|
||||
|
||||
图是 Node 半与浏览器半之间协议层的唯一真源:宿主从扫描到的包组合出 `WebBootEntry` 行,把图作为 `<head>` 中的第一个脚本注入(`window.__DSH_BOOT__`,其中 `<` 已转义,插件可控的字符串因此无法逃出 script 元素),壳则在启动任何东西之前先解析它。没有有效 manifest 的页面无法启动——浏览器侧的解析器在图缺失或畸形时大声抛错。
|
||||
图是 Node 半与浏览器半之间协议层的唯一真源:宿主从扫描到的包组合出 `WebBootEntry` 行,把图发布为一条 `global` 注入行、渲染在后续 script 行之前(`globalThis["__DSH_BOOT__"]`,其中 `<` 已转义,插件可控的字符串因此无法逃出 script 元素),壳则在启动任何东西之前先解析它。没有有效 manifest 的页面无法启动——浏览器侧的解析器在图缺失或畸形时大声抛错。
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
@@ -60,9 +60,9 @@ interface WebBootGraph {
|
||||
|
||||
包元数据——包括「非 client 包」这一否定结论——按名缓存且永不过期:插件集合的变更在重启后生效。fiber 重启原样复用其行与 rev;bundle 内容变更只经 `rebuilt()` 到达图。
|
||||
|
||||
## bundle 路由与 index 转换
|
||||
## bundle 路由与 index 注入
|
||||
|
||||
`GET`/`HEAD /plugins/<id>/client.js` 以 `no-cache` 从磁盘提供已注册的 bundle(锚定一致性的是 rev 查询参数,而非 HTTP 缓存);其他方法返回 405。未知 id——或已注册、但 bundle 因尚未构建而不可读的行——回应一个大声的 404,而不是让载体的 SPA 回退把 HTML 当作 JavaScript 发出。index 转换在每次 index 渲染时注入当前图,因此刷新页面总是针对实时组合启动。
|
||||
`GET`/`HEAD /plugins/<id>/client.js` 以 `no-cache` 从磁盘提供已注册的 bundle(锚定一致性的是 rev 查询参数,而非 HTTP 缓存);其他方法返回 405。未知 id——或已注册、但 bundle 因尚未构建而不可读的行——回应一个大声的 404,而不是让载体的 SPA 回退把 HTML 当作 JavaScript 发出。注入行在每次 index 渲染时携带当前图,因此刷新页面总是针对实时组合启动。
|
||||
|
||||
## 服务
|
||||
|
||||
@@ -82,7 +82,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
|
||||
|
||||
### `ctx.clientModules` — `ClientModuleRegistry`
|
||||
|
||||
The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index tap. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).
|
||||
The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index injection rows. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
@@ -122,5 +122,5 @@ onRebuilt(listener: (id: string, rev: string) => void): () => void
|
||||
onGraphChanged(listener: () => void): () => void
|
||||
```
|
||||
|
||||
Source: [`packages/client/modules/src/index.ts:295`](../../packages/client/modules/src/index.ts)
|
||||
Source: [`packages/client/modules/src/index.ts:282`](../../packages/client/modules/src/index.ts)
|
||||
<!-- END GENERATED cordis-surface -->
|
||||
|
||||
@@ -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 docs/subsystems/web-server.md
|
||||
web-server.md: 414863d88cdc34163882cdc3dfe8dc7c1471ac77
|
||||
web-server.zh.md: d756192ce733a3fe79c9eaf44e5d556c4bac90a8
|
||||
web-server.md: 6a33eb2bacb7dd9e85a13d7f2ef9cf870b5c44d9
|
||||
web-server.zh.md: 5dcc4b6e002c4bdeb0d839e67a750f67f40eb0a6
|
||||
|
||||
@@ -42,7 +42,7 @@ interface Config {
|
||||
|
||||
## The service
|
||||
|
||||
`WebServer` (`ctx.webServer`) listens immediately on activation; a listen failure (EADDRINUSE…) rejects initialization, and the boot process reports the failed fiber. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws because route patterns are a composition-level contract and a collision is a misconfiguration. `tapIndex(transform)` adds a pure html-to-html transform applied to every index response — `/` and each SPA fallback — in registration order; [dsh-client-modules](../../packages/client/modules) uses it to inject the boot manifest. `port` reads the listening port, including the port assigned by the OS when `config.port` is 0.
|
||||
`WebServer` (`ctx.webServer`) listens immediately on activation; a listen failure (EADDRINUSE…) rejects initialization, and the boot process reports the failed fiber. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws because route patterns are a composition-level contract and a collision is a misconfiguration. `collectIndexInjections()` gathers structured `IndexInjection` rows over one `webserver/index-inject` emit, and `renderIndex(html)` renders them into every index response — `/` and each SPA fallback — before applying the raw `tapIndex(transform)` escape-hatch transforms in registration order; [dsh-client-modules](../../packages/client/modules) answers the event with the boot manifest rows. `port` reads the listening port, including the port assigned by the OS when `config.port` is 0.
|
||||
|
||||
A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is logged as a warning and answered 400 — or the socket destroyed when headers are already out — never a process exit. Disposal pairs `close()` with `closeAllConnections()` because a handler may hold its response open (SSE) and such connections never end on their own; without the force-close, teardown would hang. The package never prints: the URL line belongs to the shell. Per-package operational detail, including the dev-mode bundle watch pipeline, stays in the [README](../../packages/host/webserver/README.md).
|
||||
|
||||
@@ -88,8 +88,9 @@ registerUpgrade(route: WebUpgradeRoute): () => void
|
||||
registerFallback(handler: WebRoute['handler']): () => void
|
||||
|
||||
/**
|
||||
* Register an index.html transform, applied by the fallback owner to every
|
||||
* index response ({@link applyIndexTaps}) in registration order.
|
||||
* Register a raw-HTML index transform, the escape hatch for markup no
|
||||
* {@link IndexInjection} row expresses: {@link renderIndex} applies taps in
|
||||
* registration order after rendering the structured rows.
|
||||
* @param transform - pure html-to-html function.
|
||||
* @returns the disposer removing the transform.
|
||||
*/
|
||||
@@ -102,7 +103,46 @@ tapIndex(transform: (html: string) => string): () => void
|
||||
* @returns the transformed body.
|
||||
*/
|
||||
applyIndexTaps(html: string): string
|
||||
|
||||
/**
|
||||
* Gather the structured injection table: one `webserver/index-inject` emit,
|
||||
* every subscriber pushes its current rows. Fresh per call, so subscribers
|
||||
* read live state (module graph, theme preference) at emit time.
|
||||
* @returns rows in subscriber activation order.
|
||||
*/
|
||||
collectIndexInjections(): IndexInjection[]
|
||||
|
||||
/**
|
||||
* Render one index.html body: the structured injection table first, then
|
||||
* the raw `tapIndex` transforms over the result.
|
||||
* @param html - the raw index.html body.
|
||||
* @returns the transformed body.
|
||||
*/
|
||||
renderIndex(html: string): string
|
||||
```
|
||||
|
||||
Source: [`packages/host/webserver/src/index.ts:59`](../../packages/host/webserver/src/index.ts)
|
||||
Source: [`packages/host/webserver/src/index.ts:73`](../../packages/host/webserver/src/index.ts)
|
||||
|
||||
<a id="webserver-events"></a>
|
||||
|
||||
### `webserver/*` events
|
||||
|
||||
<a id="webserverindex-inject--emit"></a>
|
||||
|
||||
#### `webserver/index-inject` — emit
|
||||
|
||||
Collect the structured index injection table. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row's data is read fresh at emit time.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Collect the structured index injection table. Emitted on every index
|
||||
* render and every worker boot-payload request; listeners push their
|
||||
* current rows, so a row's data is read fresh at emit time.
|
||||
* @param table - Mutable row table; listeners append in activation order.
|
||||
* @mode emit
|
||||
*/
|
||||
'webserver/index-inject'(table: IndexInjection[]): void
|
||||
```
|
||||
|
||||
Source: [`packages/host/webserver/src/index.ts:34`](../../packages/host/webserver/src/index.ts)
|
||||
<!-- END GENERATED cordis-surface -->
|
||||
|
||||
@@ -42,7 +42,7 @@ interface Config {
|
||||
|
||||
## 服务
|
||||
|
||||
`WebServer`(`ctx.webServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会使初始化被拒绝,启动进程会报告失败的 fiber。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。`tapIndex(transform)` 添加一个纯 HTML 到 HTML 转换函数,按注册顺序应用于每个 index 响应(`/` 和每次 SPA 回退);[dsh-client-modules](../../packages/client/modules) 用它注入启动 manifest(元数据清单)。`port` 读取监听端口,包括 `config.port` 为 0 时操作系统分配的端口。
|
||||
`WebServer`(`ctx.webServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会使初始化被拒绝,启动进程会报告失败的 fiber。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。`collectIndexInjections()` 经一次 `webserver/index-inject` emit 收集结构化 `IndexInjection` 行,`renderIndex(html)` 把它们渲染进每个 index 响应(`/` 和每次 SPA 回退),随后再按注册顺序应用原始的 `tapIndex(transform)` 逃生口转换;[dsh-client-modules](../../packages/client/modules) 以启动 manifest(元数据清单)行回应该事件。`port` 读取监听端口,包括 `config.port` 为 0 时操作系统分配的端口。
|
||||
|
||||
处理过程中抛出异常的请求(畸形的 % 转义撞上 `decodeURIComponent`、客户端在请求体中途断开)会记录为警告并应答 400(响应头已发出时则销毁 socket),绝不导致进程退出。dispose(资源释放)把 `close()` 与 `closeAllConnections()` 配对使用,因为处理器可能像 SSE(Server-Sent Events)那样保持响应打开,而这类连接永远不会自行结束;没有强制关闭,拆卸就会挂起。该包从不打印输出:URL 行归 shell 所有。逐包运维细节(含开发模式的 bundle 监视流水线)留在 [README](../../packages/host/webserver/README.md) 中。
|
||||
|
||||
@@ -88,8 +88,9 @@ registerUpgrade(route: WebUpgradeRoute): () => void
|
||||
registerFallback(handler: WebRoute['handler']): () => void
|
||||
|
||||
/**
|
||||
* Register an index.html transform, applied by the fallback owner to every
|
||||
* index response ({@link applyIndexTaps}) in registration order.
|
||||
* Register a raw-HTML index transform, the escape hatch for markup no
|
||||
* {@link IndexInjection} row expresses: {@link renderIndex} applies taps in
|
||||
* registration order after rendering the structured rows.
|
||||
* @param transform - pure html-to-html function.
|
||||
* @returns the disposer removing the transform.
|
||||
*/
|
||||
@@ -102,7 +103,46 @@ tapIndex(transform: (html: string) => string): () => void
|
||||
* @returns the transformed body.
|
||||
*/
|
||||
applyIndexTaps(html: string): string
|
||||
|
||||
/**
|
||||
* Gather the structured injection table: one `webserver/index-inject` emit,
|
||||
* every subscriber pushes its current rows. Fresh per call, so subscribers
|
||||
* read live state (module graph, theme preference) at emit time.
|
||||
* @returns rows in subscriber activation order.
|
||||
*/
|
||||
collectIndexInjections(): IndexInjection[]
|
||||
|
||||
/**
|
||||
* Render one index.html body: the structured injection table first, then
|
||||
* the raw `tapIndex` transforms over the result.
|
||||
* @param html - the raw index.html body.
|
||||
* @returns the transformed body.
|
||||
*/
|
||||
renderIndex(html: string): string
|
||||
```
|
||||
|
||||
Source: [`packages/host/webserver/src/index.ts:59`](../../packages/host/webserver/src/index.ts)
|
||||
Source: [`packages/host/webserver/src/index.ts:73`](../../packages/host/webserver/src/index.ts)
|
||||
|
||||
<a id="webserver-events"></a>
|
||||
|
||||
### `webserver/*` events
|
||||
|
||||
<a id="webserverindex-inject--emit"></a>
|
||||
|
||||
#### `webserver/index-inject` — emit
|
||||
|
||||
Collect the structured index injection table. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row's data is read fresh at emit time.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Collect the structured index injection table. Emitted on every index
|
||||
* render and every worker boot-payload request; listeners push their
|
||||
* current rows, so a row's data is read fresh at emit time.
|
||||
* @param table - Mutable row table; listeners append in activation order.
|
||||
* @mode emit
|
||||
*/
|
||||
'webserver/index-inject'(table: IndexInjection[]): void
|
||||
```
|
||||
|
||||
Source: [`packages/host/webserver/src/index.ts:34`](../../packages/host/webserver/src/index.ts)
|
||||
<!-- END GENERATED cordis-surface -->
|
||||
|
||||
@@ -52,6 +52,14 @@ interface RemoteNamespaceHandle {
|
||||
readonly dispose: TypertDisposer
|
||||
}
|
||||
|
||||
/** One descriptor's mounted variants, for the group disposer to unwind. */
|
||||
interface InstalledMethod {
|
||||
readonly descriptor: InvocationDescriptor
|
||||
readonly token: MountToken
|
||||
direct: boolean
|
||||
scoped: boolean
|
||||
}
|
||||
|
||||
/** Typed Remote service augmented by generated direct namespaces. */
|
||||
export type ClientRemote = TypertClientRemote
|
||||
|
||||
@@ -177,9 +185,17 @@ class ClientRemoteService extends Service implements TypertClientRemote {
|
||||
): Promise<TypertDisposer> {
|
||||
this.validateContribution(contribution)
|
||||
const disposeRemote = callerCtx.typert.remotes.register(contribution)
|
||||
const groups = new Map<string, InvocationDescriptor[]>()
|
||||
for (const descriptor of contribution.descriptors) {
|
||||
const group = groups.get(descriptor.namespace)
|
||||
if (group === undefined) groups.set(descriptor.namespace, [descriptor])
|
||||
else group.push(descriptor)
|
||||
}
|
||||
const installed: TypertDisposer[] = []
|
||||
try {
|
||||
for (const descriptor of contribution.descriptors) installed.push(await this.install(descriptor))
|
||||
for (const [namespace, descriptors] of groups) {
|
||||
installed.push(await this.installNamespace(namespace, descriptors))
|
||||
}
|
||||
} catch (error) {
|
||||
for (const dispose of installed.reverse()) await dispose()
|
||||
await disposeRemote()
|
||||
@@ -235,66 +251,47 @@ class ClientRemoteService extends Service implements TypertClientRemote {
|
||||
}
|
||||
}
|
||||
|
||||
private async install(descriptor: InvocationDescriptor): Promise<TypertDisposer> {
|
||||
const token: MountToken = { active: true, abort: new AbortController() }
|
||||
const installed: TypertDisposer[] = []
|
||||
try {
|
||||
if (descriptor.invocation.kind === 'direct') {
|
||||
installed.push(await this.installDirect(descriptor, token))
|
||||
}
|
||||
const projection = scopedProjection(descriptor)
|
||||
if (projection !== undefined) installed.push(await this.installScoped(descriptor, projection, token))
|
||||
} catch (error) {
|
||||
token.active = false
|
||||
token.abort.abort()
|
||||
for (const dispose of installed.reverse()) await dispose()
|
||||
throw error
|
||||
}
|
||||
return async () => {
|
||||
/* v8 ignore next -- Cordis effect disposers are idempotent and invoke this cleanup at most once. */
|
||||
if (!token.active) return
|
||||
token.active = false
|
||||
token.abort.abort()
|
||||
for (const dispose of installed.reverse()) await dispose()
|
||||
}
|
||||
}
|
||||
|
||||
private async installDirect(descriptor: InvocationDescriptor, token: MountToken): Promise<TypertDisposer> {
|
||||
const namespace = await this.namespace(descriptor.namespace)
|
||||
try {
|
||||
namespace.service.installDirect(descriptor, token)
|
||||
} catch (error) {
|
||||
await this.disposeNamespace(descriptor.namespace, namespace)
|
||||
throw error
|
||||
}
|
||||
return async () => {
|
||||
namespace.service.remove('direct', descriptor.method, token)
|
||||
await this.disposeNamespace(descriptor.namespace, namespace)
|
||||
}
|
||||
}
|
||||
|
||||
private async installScoped(
|
||||
descriptor: InvocationDescriptor,
|
||||
projection: ScopedProjection,
|
||||
token: MountToken,
|
||||
/**
|
||||
* Mount one namespace's descriptor group with no visibility gap: a fresh
|
||||
* namespace installs its whole group synchronously inside its fiber's
|
||||
* apply, so a plugin parked on the namespace service never observes it
|
||||
* without the methods the same contribution carries; an existing namespace
|
||||
* takes the group in one synchronous step.
|
||||
* @param name - Remote namespace.
|
||||
* @param descriptors - Every contribution descriptor naming that namespace.
|
||||
* @returns disposer unmounting the group and the namespace once empty.
|
||||
*/
|
||||
private async installNamespace(
|
||||
name: string,
|
||||
descriptors: readonly InvocationDescriptor[],
|
||||
): Promise<TypertDisposer> {
|
||||
const namespace = await this.namespace(descriptor.namespace)
|
||||
try {
|
||||
namespace.service.installScoped(descriptor, projection, token)
|
||||
} catch (error) {
|
||||
await this.disposeNamespace(descriptor.namespace, namespace)
|
||||
throw error
|
||||
let namespace = this.namespaces.get(name)
|
||||
let installed: InstalledMethod[]
|
||||
if (namespace === undefined) {
|
||||
({ namespace, installed } = await this.createNamespace(name, descriptors))
|
||||
} else {
|
||||
installed = installMethods(namespace.service, descriptors)
|
||||
}
|
||||
const handle = namespace
|
||||
return async () => {
|
||||
namespace.service.remove('scoped', descriptor.method, token)
|
||||
await this.disposeNamespace(descriptor.namespace, namespace)
|
||||
for (const method of [...installed].reverse()) {
|
||||
/* v8 ignore next -- Cordis effect disposers are idempotent and invoke this cleanup at most once. */
|
||||
if (!method.token.active) continue
|
||||
method.token.active = false
|
||||
method.token.abort.abort()
|
||||
if (method.scoped) handle.service.remove('scoped', method.descriptor.method, method.token)
|
||||
if (method.direct) handle.service.remove('direct', method.descriptor.method, method.token)
|
||||
}
|
||||
await this.disposeNamespace(name, handle)
|
||||
}
|
||||
}
|
||||
|
||||
private async namespace(name: string): Promise<RemoteNamespaceHandle> {
|
||||
let namespace = this.namespaces.get(name)
|
||||
if (namespace !== undefined) return namespace
|
||||
private async createNamespace(
|
||||
name: string,
|
||||
descriptors: readonly InvocationDescriptor[],
|
||||
): Promise<{ namespace: RemoteNamespaceHandle; installed: InstalledMethod[] }> {
|
||||
let service: RemoteNamespaceService | undefined
|
||||
let installed: InstalledMethod[] | undefined
|
||||
const fiber = this.ownerCtx.plugin({
|
||||
name: remoteServiceKey(name),
|
||||
apply: (ctx: Context) => {
|
||||
@@ -303,6 +300,9 @@ class ClientRemoteService extends Service implements TypertClientRemote {
|
||||
name,
|
||||
(direct, scoped, caller, args) => this.invokeMethod(direct, scoped, caller, args),
|
||||
)
|
||||
// Same synchronous window as the service registration: a dependent the
|
||||
// new service unparks runs only after the methods exist.
|
||||
installed = installMethods(service, descriptors)
|
||||
},
|
||||
})
|
||||
try {
|
||||
@@ -311,11 +311,13 @@ class ClientRemoteService extends Service implements TypertClientRemote {
|
||||
await fiber.dispose()
|
||||
throw error
|
||||
}
|
||||
/* v8 ignore next -- a settled namespace fiber synchronously constructs its Service. */
|
||||
if (service === undefined) throw new Error(`client api: namespace ${JSON.stringify(name)} did not start`)
|
||||
namespace = { service, dispose: fiber.dispose }
|
||||
/* v8 ignore next 3 -- a settled namespace fiber synchronously constructs its Service and installs the group. */
|
||||
if (service === undefined || installed === undefined) {
|
||||
throw new Error(`client api: namespace ${JSON.stringify(name)} did not start`)
|
||||
}
|
||||
const namespace = { service, dispose: fiber.dispose }
|
||||
this.namespaces.set(name, namespace)
|
||||
return namespace
|
||||
return { namespace, installed }
|
||||
}
|
||||
|
||||
private async disposeNamespace(name: string, namespace: RemoteNamespaceHandle): Promise<void> {
|
||||
@@ -504,6 +506,49 @@ class RemoteNamespaceService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install one descriptor group on a namespace service, unwinding the partial
|
||||
* group when a descriptor is refused.
|
||||
* @param service - Namespace service taking the methods.
|
||||
* @param descriptors - Descriptor group of one contribution.
|
||||
* @returns per-descriptor records for the group disposer.
|
||||
*/
|
||||
function installMethods(
|
||||
service: RemoteNamespaceService,
|
||||
descriptors: readonly InvocationDescriptor[],
|
||||
): InstalledMethod[] {
|
||||
const installed: InstalledMethod[] = []
|
||||
try {
|
||||
for (const descriptor of descriptors) {
|
||||
const method: InstalledMethod = {
|
||||
descriptor,
|
||||
token: { active: true, abort: new AbortController() },
|
||||
direct: false,
|
||||
scoped: false,
|
||||
}
|
||||
installed.push(method)
|
||||
if (descriptor.invocation.kind === 'direct') {
|
||||
service.installDirect(descriptor, method.token)
|
||||
method.direct = true
|
||||
}
|
||||
const projection = scopedProjection(descriptor)
|
||||
if (projection !== undefined) {
|
||||
service.installScoped(descriptor, projection, method.token)
|
||||
method.scoped = true
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
for (const method of [...installed].reverse()) {
|
||||
method.token.active = false
|
||||
method.token.abort.abort()
|
||||
if (method.scoped) service.remove('scoped', method.descriptor.method, method.token)
|
||||
if (method.direct) service.remove('direct', method.descriptor.method, method.token)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return installed
|
||||
}
|
||||
|
||||
const REMOTE_NAMESPACE_FIELDS = new Set(['ctx', 'empty', 'invokeRemote', 'methods', 'name', 'namespace'])
|
||||
|
||||
function remoteServiceKey(namespace: string): string {
|
||||
|
||||
@@ -458,6 +458,91 @@ describe('Client Typert API', () => {
|
||||
await disposeContext()
|
||||
})
|
||||
|
||||
it('unwinds an already-installed namespace when a later namespace fails to install', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const { scope: _scope, ...probe } = directDescriptor()
|
||||
const vault: InvocationDescriptor = {
|
||||
...probe,
|
||||
id: '@fixture/vault#vault/seal',
|
||||
service: 'vault',
|
||||
namespace: 'vault',
|
||||
method: 'seal',
|
||||
}
|
||||
const defineProperty = Object.defineProperty
|
||||
const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
|
||||
if (key === 'seal') throw new Error('fixture later-namespace failure')
|
||||
return defineProperty(target, key, attributes)
|
||||
})
|
||||
try {
|
||||
await expect(ctx.remote.$mount({ package: '@fixture/two-namespaces', descriptors: [probe, vault] }))
|
||||
.rejects.toThrow('fixture later-namespace failure')
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
|
||||
expect((ctx.remote as unknown as Record<string, unknown>).probe).toBeUndefined()
|
||||
expect((ctx.remote as unknown as Record<string, unknown>).vault).toBeUndefined()
|
||||
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
|
||||
})
|
||||
|
||||
it('rolls back an earlier scoped projection when a later descriptor fails to install', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const { scope: _scope, ...direct } = directDescriptor()
|
||||
const failing: InvocationDescriptor = {
|
||||
...direct,
|
||||
id: '@fixture/probe#probe/archive',
|
||||
method: 'archive',
|
||||
}
|
||||
const defineProperty = Object.defineProperty
|
||||
const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
|
||||
if (key === 'archive') throw new Error('fixture trailing failure')
|
||||
return defineProperty(target, key, attributes)
|
||||
})
|
||||
try {
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/scoped-then-failing',
|
||||
descriptors: [contextDescriptor(), failing],
|
||||
})).rejects.toThrow('fixture trailing failure')
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
|
||||
expect((ctx.remote as unknown as Record<string, unknown>).probe).toBeUndefined()
|
||||
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
|
||||
})
|
||||
|
||||
it('keeps a namespace another contribution still populates when a group leaves', async () => {
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
.mockResolvedValue({ ok: true, value: { renamed: true } })
|
||||
const ctx = await bench(call)
|
||||
const { scope: _scope, ...direct } = directDescriptor()
|
||||
const disposeDirect = await ctx.remote.$mount({ package: '@fixture/direct-owner', descriptors: [direct] })
|
||||
const disposeScoped = await ctx.remote.$mount({ package: '@fixture/scoped-owner', descriptors: [contextDescriptor()] })
|
||||
|
||||
await disposeDirect()
|
||||
// The namespace survives its first group: the second contribution still owns methods on it.
|
||||
const surviving = ctx.get('remote.probe') as unknown as Record<string, unknown> | undefined
|
||||
expect(surviving).toBeDefined()
|
||||
expect(surviving?.create).toBeUndefined()
|
||||
await disposeScoped()
|
||||
expect(ctx.get('remote.probe')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('unparks a namespace dependent only after its contribution methods exist', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const { scope: _scope, ...direct } = directDescriptor()
|
||||
let observed: string | undefined
|
||||
// Parked before the mount: the unpark moment is the observation — the
|
||||
// atomic-visibility guarantee says the service never appears methodless.
|
||||
const parked = ctx.inject(['remote.probe'], (probeCtx) => {
|
||||
observed = typeof (probeCtx.get('remote.probe') as { create?: unknown } | undefined)?.create
|
||||
})
|
||||
const dispose = await ctx.remote.$mount({ package: '@fixture/atomic-visibility', descriptors: [direct] })
|
||||
await parked
|
||||
expect(observed).toBe('function')
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('rejects weak parameter and Context codecs plus malformed scope projections', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const direct = directDescriptor()
|
||||
|
||||
@@ -78,7 +78,7 @@ function fakeHttpServer(host: '127.0.0.1' | '0.0.0.0' = '127.0.0.1'): { server:
|
||||
fallback = handler
|
||||
return () => { fallback = undefined }
|
||||
},
|
||||
applyIndexTaps: (html: string) => html,
|
||||
renderIndex: (html: string) => html,
|
||||
} as unknown as WebServer
|
||||
return { server, seat: () => fallback }
|
||||
}
|
||||
|
||||
@@ -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/client/connection/README.md
|
||||
README.md: f0a707cc5f3a962c7852f7323c727d0a39a57b10
|
||||
README.zh.md: b529eebf9af93e36b6b92c19964678b7f5a04ea1
|
||||
README.md: a7562b9dac57930b1abc0b76b9079a6865a38b35
|
||||
README.zh.md: c18740e98721a9aa7ec95037da45c739c58a06f7
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + observable generation-scoped `hostDescription` + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` abstraction, and the loop's sink/config types. Each successful readiness handshake publishes the exact `host.describe` value before `onConnected`; generation loss and explicit stop clear it, so native-capability consumers never retain a disconnected answer. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. The Host half owns the single `/api` route and its Fetch bridge; a registered Typert interceptor claims its Remote endpoints before the API Proxy fallback. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from — and the agent-preset authoring plane, `agentPreset.read`/`copy`/`openDocument`/`remove`, since a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop (authoring is copy-only, so none of them accepts composition text or a path); `agentPreset.list` and `agentPreset.select` stay out — the roster carries only ids and trust, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md).
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + observable generation-scoped `hostDescription` + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` abstraction, and the loop's sink/config types. Each successful readiness handshake publishes the exact `host.describe` value before `onConnected`; generation loss and explicit stop clear it, so native-capability consumers never retain a disconnected answer. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. The exported `ClientTransportHooks` names the page global `__DSH_TRANSPORT__` that replaces the browser carrier wholesale: the served web app leaves it unset and gets HTTP + WebSocket, while a shell owning a different physical transport (the worker preview's postMessage tunnel) provides `createApiClient` and `fetch` — plus `loadBundle` when it also owns bundle bytes — instead of forking the plugin. The Host half owns the single `/api` route and its Fetch bridge; a registered Typert interceptor claims its Remote endpoints before the API Proxy fallback. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from — and the agent-preset authoring plane, `agentPreset.read`/`copy`/`openDocument`/`remove`, since a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop (authoring is copy-only, so none of them accepts composition text or a path); `agentPreset.list` and `agentPreset.select` stay out — the roster carries only ids and trust, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md).
|
||||
|
||||
## /api browser-trust fence
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 可观察且按 generation 生效的 `hostDescription` + 单消费方流循环启动器);导出表层携带协议约定类型、`AbstractApiClient` 抽象,以及循环的 sink/配置类型。每次就绪握手成功后,都会在 `onConnected` 之前发布完整的 `host.describe` 值;generation 失效或显式 stop 会清空它,因此原生能力消费者不会保留已经断线的判断。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Host half 持有唯一 `/api` route 及其 Fetch bridge;已注册的 Typert interceptor 会先认领自己的 Remote endpoint,未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处——以及 agent(智能体) preset 的创作面 `agentPreset.read`/`copy`/`openDocument`/`remove`,因为组装指明了一个会话所运行的插件,读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面(创作只有复制一种写入,因此这些方法都不接收组装文本或路径);`agentPreset.list` 与 `agentPreset.select` 不在其中——名单只携带 id 与信任级别,而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md)。
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 可观察且按 generation 生效的 `hostDescription` + 单消费方流循环启动器);导出表层携带协议约定类型、`AbstractApiClient` 抽象,以及循环的 sink/配置类型。每次就绪握手成功后,都会在 `onConnected` 之前发布完整的 `host.describe` 值;generation 失效或显式 stop 会清空它,因此原生能力消费者不会保留已经断线的判断。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。导出的 `ClientTransportHooks` 命名了整体替换浏览器载体的页面全局量 `__DSH_TRANSPORT__`:served web app 不设置它、走 HTTP + WebSocket;拥有另一种物理传输的壳(worker 预览的 postMessage 隧道)则在此提供 `createApiClient` 与 `fetch`——当它同时持有 bundle 字节时再加 `loadBundle`——而不必 fork 本插件。Host half 持有唯一 `/api` route 及其 Fetch bridge;已注册的 Typert interceptor 会先认领自己的 Remote endpoint,未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处——以及 agent(智能体) preset 的创作面 `agentPreset.read`/`copy`/`openDocument`/`remove`,因为组装指明了一个会话所运行的插件,读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面(创作只有复制一种写入,因此这些方法都不接收组装文本或路径);`agentPreset.list` 与 `agentPreset.select` 不在其中——名单只携带 id 与信任级别,而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md)。
|
||||
|
||||
## /api 浏览器信任栅栏
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { HostDescription, IApiClient } from './api.ts'
|
||||
import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts'
|
||||
import { FixtureApiClient } from './fixture.ts'
|
||||
import { WebApiClient } from './web-api-client.ts'
|
||||
import { createWebConnectionRpc } from './rpc.ts'
|
||||
import { createWebConnectionRpc, type RpcFetch } from './rpc.ts'
|
||||
import { isLoopbackHostname } from '../loopback-hostname.ts'
|
||||
import type { ClientConnectionRpc } from '../rpc.ts'
|
||||
|
||||
@@ -40,6 +40,7 @@ export {
|
||||
// controller remains package-internal.
|
||||
export type { ConnectionConfig, ConnectionSinks, ConnectionState }
|
||||
export type { ClientConnectionRpc } from '../rpc.ts'
|
||||
export type { RpcFetch } from './rpc.ts'
|
||||
|
||||
/** Observable Host description published by each completed connection handshake. */
|
||||
export interface HostDescriptionSource {
|
||||
@@ -52,6 +53,30 @@ export interface HostDescriptionSource {
|
||||
/** Required services (none — this is the wire root). */
|
||||
export const inject: string[] = []
|
||||
|
||||
/**
|
||||
* Carrier override installed on the page global before plugin boot. The served
|
||||
* web app leaves it unset and gets HTTP + WebSocket; a shell that owns a
|
||||
* different physical transport (the worker preview's postMessage tunnel)
|
||||
* provides both halves here instead of forking this plugin.
|
||||
*/
|
||||
export interface ClientTransportHooks {
|
||||
/** Build the API carrier: unary calls plus the two downstream event streams. */
|
||||
createApiClient(): IApiClient
|
||||
/** Transport for generic unary RPC channels (the Typert gateway). */
|
||||
fetch: RpcFetch
|
||||
/**
|
||||
* Bundle transport for the module system, present when the carrier also owns
|
||||
* bundle bytes (the worker tunnel). Absent in the served web app, whose
|
||||
* bundles load over HTTP.
|
||||
*/
|
||||
loadBundle?(url: string): Promise<void>
|
||||
}
|
||||
|
||||
/** Page global carrying {@link ClientTransportHooks}; absent in the served web app. */
|
||||
interface ClientTransportGlobal {
|
||||
__DSH_TRANSPORT__?: ClientTransportHooks
|
||||
}
|
||||
|
||||
/**
|
||||
* The ctx.connection service API: the API client plus a one-shot
|
||||
* controller starter (the runtime plugin supplies sinks when its object layer
|
||||
@@ -85,8 +110,9 @@ export function apply(ctx: Context): void {
|
||||
const pageLocation = typeof location === 'undefined' ? undefined : location
|
||||
const fixture = pageLocation !== undefined && new URLSearchParams(pageLocation.search).has('fixture')
|
||||
const fixtureClient = fixture ? new FixtureApiClient() : undefined
|
||||
const api: IApiClient = fixtureClient ?? new WebApiClient()
|
||||
const rpc = fixtureClient?.rpc ?? createWebConnectionRpc()
|
||||
const transport = (globalThis as ClientTransportGlobal).__DSH_TRANSPORT__
|
||||
const api: IApiClient = fixtureClient ?? transport?.createApiClient() ?? new WebApiClient()
|
||||
const rpc = fixtureClient?.rpc ?? createWebConnectionRpc(transport?.fetch)
|
||||
let started = false
|
||||
let description: HostDescription | undefined
|
||||
const descriptionListeners = new Set<() => void>()
|
||||
|
||||
@@ -12,11 +12,16 @@ const INTERNAL_BASE = 'http://dsh.internal'
|
||||
const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/
|
||||
const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
|
||||
|
||||
/** Transport this caller posts through; same signature as the global `fetch`. */
|
||||
export type RpcFetch = (input: URL, init: RequestInit) => Promise<Response>
|
||||
|
||||
/**
|
||||
* Create the browser-backed generic RPC caller.
|
||||
* @param doFetch - transport override; defaults to the page's global fetch.
|
||||
* @returns caller that owns request correlation and response-envelope validation.
|
||||
*/
|
||||
export function createWebConnectionRpc(): ClientConnectionRpc {
|
||||
export function createWebConnectionRpc(doFetch?: RpcFetch): ClientConnectionRpc {
|
||||
const send: RpcFetch = doFetch ?? ((input, init) => globalThis.fetch(input, init))
|
||||
return {
|
||||
async call(channel, endpoint, payload, signal) {
|
||||
assertTarget(channel, endpoint)
|
||||
@@ -27,7 +32,7 @@ export function createWebConnectionRpc(): ClientConnectionRpc {
|
||||
method: endpoint,
|
||||
payload,
|
||||
}
|
||||
const response = await globalThis.fetch(
|
||||
const response = await send(
|
||||
new URL(`${channel}/${endpoint}`, resolveBase()),
|
||||
{
|
||||
method: 'POST',
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
* the host Loader's entries for packages declaring `dsh.client`, composes the
|
||||
* `window.__DSH_BOOT__` entry graph (wire single source: {@link WebBootEntry}
|
||||
* in `./client/manifest.ts`) in module-graph order, serves
|
||||
* `/plugins/<id>/client.js` and its source map, taps the index render to
|
||||
* inject the boot manifest plus the parser-blocking bootstrap preloads, and
|
||||
* provides the `clientModuleHost` service (the HMR node half's
|
||||
* registration/notification face).
|
||||
* `/plugins/<id>/client.js` and its source map, contributes the boot manifest
|
||||
* plus the parser-blocking bootstrap preloads to the webserver's index
|
||||
* injection table, and provides the `clientModuleHost` service (the HMR node
|
||||
* half's registration/notification face).
|
||||
*
|
||||
* Scanning is incremental per package — there is no full-rescan code path.
|
||||
* Every cordis `internal/plugin` emission (fiber construction/disposal) marks
|
||||
@@ -30,7 +30,7 @@ import { dirname, join } from 'node:path'
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/cordis-plugin-loader'
|
||||
import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { optionalStringArray, stripClientSuffix } from './client/manifest.ts'
|
||||
import type { WebBootEntry, WebBootGraph } from './client/manifest.ts'
|
||||
|
||||
@@ -228,31 +228,19 @@ const CLIENT_RUNTIME_ID = '@deepseek-ai/dsh-client-runtime'
|
||||
/** Ordinary dynamic bundles the HTML parser executes before the Vite shell. */
|
||||
const PARSER_PRELOAD_IDS = [CLIENT_MODULES_ID, CLIENT_RUNTIME_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('>', '>')
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject the boot protocol into index.html. The inline registration queue precedes
|
||||
* blocking classic scripts for modules' and runtime's ordinary
|
||||
* The boot protocol as index injection rows. The inline registration queue
|
||||
* precedes blocking classic scripts for modules' and runtime's ordinary
|
||||
* `lib/client.js` artifacts. Its `create()` method materializes the modules
|
||||
* bundle, delegates construction to that bundle, and leaves the same facade
|
||||
* in live-registration mode. The graph script follows before the shell reads
|
||||
* it. `<` is escaped in JSON so a plugin-controlled string cannot break out
|
||||
* of the script element.
|
||||
* @param html - the index.html source.
|
||||
* in live-registration mode. The graph global follows before the shell reads
|
||||
* it.
|
||||
* @param graph - the composed entry graph.
|
||||
* @returns the html with the graph script injected.
|
||||
* @returns head rows in execution order: queue script, preload scripts, graph global.
|
||||
*/
|
||||
export function injectBootManifest(html: string, graph: WebBootGraph): string {
|
||||
const json = JSON.stringify(graph).replaceAll('<', '\\u003c')
|
||||
export function bootInjections(graph: WebBootGraph): IndexInjection[] {
|
||||
const bootstrapId = JSON.stringify(CLIENT_MODULES_ID)
|
||||
const queue = `<script>(()=>{
|
||||
const queue = `(()=>{
|
||||
const pendingQueue=[]
|
||||
window.__ModuleLoader__={
|
||||
mode:"queue",
|
||||
@@ -273,21 +261,20 @@ window.__ModuleLoader__={
|
||||
return exports.createClientModuleSystem(this,{id:registration.id,exports},options)
|
||||
}
|
||||
}
|
||||
})()</script>`
|
||||
})()`
|
||||
const preload = PARSER_PRELOAD_IDS.map(id => graph.entries.find(entry => entry.id === id))
|
||||
.filter((entry): entry is WebBootEntry => entry !== undefined)
|
||||
.map(entry => `<script src="${escapeHtmlAttribute(entry.url)}"></script>`)
|
||||
.join('')
|
||||
const script = `${queue}${preload}<script>window.__DSH_BOOT__ = ${json}</script>`
|
||||
const head = html.indexOf('<head>')
|
||||
if (head !== -1) return `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}`
|
||||
// Headless fixture pages may lack <head>; prepending keeps the read-before-shell ordering.
|
||||
return `${script}${html}`
|
||||
.map((entry): IndexInjection => ({ kind: 'script-src', placement: 'head', src: entry.url }))
|
||||
return [
|
||||
{ kind: 'script', placement: 'head', text: queue },
|
||||
...preload,
|
||||
{ kind: 'global', name: '__DSH_BOOT__', value: graph },
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* The web plugin table service: incremental `dsh.client` scan + wire composition
|
||||
* + bundle route + index tap. Construction runs the activation scan
|
||||
* + bundle route + index injection rows. Construction runs the activation scan
|
||||
* synchronously — a malformed declaration or missing bundle among the
|
||||
* already-loaded entries aggregates into one loud throw (FAILED fiber; the
|
||||
* boot activation audit reports it).
|
||||
@@ -353,10 +340,9 @@ export class ClientModuleRegistry extends Service {
|
||||
() => ctx.webServer.register({ kind: 'prefix', path: '/plugins', handler: this.serveBundle }),
|
||||
'client-modules: bundle route',
|
||||
)
|
||||
ctx.effect(
|
||||
() => ctx.webServer.tapIndex(html => injectBootManifest(html, this.composed)),
|
||||
'client-modules: boot manifest injection',
|
||||
)
|
||||
ctx.on('webserver/index-inject', (table) => {
|
||||
table.push(...bootInjections(this.composed))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,9 +8,9 @@ import { pathToFileURL } from 'node:url'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { WebServer, WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { renderIndexInjections, type WebServer, type WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import * as modulesClient from '../src/client/index.ts'
|
||||
import { ClientModuleRegistry, injectBootManifest, orderByModuleGraph } from '../src/index.ts'
|
||||
import { ClientModuleRegistry, bootInjections, orderByModuleGraph } from '../src/index.ts'
|
||||
import type { ClientModuleLoaderTarget, WebBootEntry, WebBootGraph } from '../src/client/index.ts'
|
||||
|
||||
const MODULES_ID = '@deepseek-ai/dsh-client-modules'
|
||||
@@ -81,9 +81,12 @@ function construct(packageNames: string[]): ClientModuleRegistry {
|
||||
return constructWithRoute(packageNames).service
|
||||
}
|
||||
|
||||
/** Execute the exact first inline script emitted by the Host HTML transform. */
|
||||
/** Execute the exact first inline script emitted by the Host boot rows. */
|
||||
function injectedFacade(graph: WebBootGraph): { html: string; target: ClientModuleLoaderTarget } {
|
||||
const html = injectBootManifest('<html><head></head><body><script type="module" src="/index.js"></script></body></html>', graph)
|
||||
const html = renderIndexInjections(
|
||||
'<html><head></head><body><script type="module" src="/index.js"></script></body></html>',
|
||||
bootInjections(graph),
|
||||
)
|
||||
const source = /<head><script>([\s\S]*?)<\/script>/.exec(html)?.[1]
|
||||
if (source === undefined) throw new Error('missing injected ModuleLoader facade script')
|
||||
const window: { __ModuleLoader__?: ClientModuleLoaderTarget } = {}
|
||||
@@ -107,7 +110,7 @@ describe('HTML bootstrap facade', () => {
|
||||
const facadeAt = html.indexOf('window.__ModuleLoader__=')
|
||||
const modulesAt = html.indexOf('<script src="/plugins/modules.js?rev=m"></script>')
|
||||
const runtimeAt = html.indexOf('<script src="/plugins/runtime.js?rev=r"></script>')
|
||||
const graphAt = html.indexOf('window.__DSH_BOOT__ = ')
|
||||
const graphAt = html.indexOf('globalThis["__DSH_BOOT__"] = ')
|
||||
const entryAt = html.indexOf('<script type="module" src="/index.js"></script>')
|
||||
expect([facadeAt, modulesAt, runtimeAt, graphAt, entryAt]).toEqual([...new Set([
|
||||
facadeAt, modulesAt, runtimeAt, graphAt, entryAt,
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
@@ -65,10 +66,9 @@
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"react": "^18.2.0",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^"
|
||||
"@types/react": "~18.3.1",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
/**
|
||||
* Host-rendered theme bootstrap for the browser's pre-plugin interval. Each
|
||||
* index response embeds the current durable built-in preference; the browser
|
||||
* resolves only `system`, then writes the same DOM fields ui-layout's
|
||||
* ThemePresenter owns after the client plugin tree activates.
|
||||
* Theme bootstrap row for the browser's pre-plugin interval. Each index
|
||||
* render embeds the current durable built-in preference; the browser resolves
|
||||
* only `system`, then writes the same DOM fields ui-layout's ThemePresenter
|
||||
* owns after the client plugin tree activates.
|
||||
*/
|
||||
|
||||
import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { DEFAULT_PREFERENCE, type ThemePreference } from './theme-settings.ts'
|
||||
|
||||
/** Build the inline script for one schema-validated built-in preference. */
|
||||
/** Build the inline script body for one schema-validated built-in preference. */
|
||||
function bootThemeScript(preference: ThemePreference): string {
|
||||
return `<script>(() => {
|
||||
return `(() => {
|
||||
const preference = ${JSON.stringify(preference)}
|
||||
const systemDark = preference === 'system'
|
||||
&& typeof matchMedia !== 'undefined'
|
||||
@@ -17,24 +18,17 @@ function bootThemeScript(preference: ThemePreference): string {
|
||||
const dark = preference === 'dark' || systemDark
|
||||
document.documentElement.style.colorScheme = dark ? 'dark' : 'light'
|
||||
document.body.toggleAttribute('data-ds-dark-theme', dark)
|
||||
})()</script>`
|
||||
})()`
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert the theme bootstrap immediately after the opening body tag, before
|
||||
* the shell mount and module script. Body-less fragments receive it at the
|
||||
* end, where the HTML parser has already synthesized a body.
|
||||
* @param html - Raw application index HTML.
|
||||
* The theme bootstrap as an injection row: an inline script immediately after
|
||||
* the opening body tag, before the shell mount and module script.
|
||||
* @param preference - Current Host-backed built-in preference.
|
||||
* @returns HTML containing the theme bootstrap.
|
||||
* @returns the body script row.
|
||||
*/
|
||||
export function injectBootTheme(
|
||||
html: string,
|
||||
export function bootThemeInjection(
|
||||
preference: ThemePreference = DEFAULT_PREFERENCE,
|
||||
): string {
|
||||
const script = bootThemeScript(preference)
|
||||
const body = /<body(?:\s[^>]*)?>/i.exec(html)
|
||||
if (body === null) return `${html}${script}`
|
||||
const at = body.index + body[0].length
|
||||
return `${html.slice(0, at)}${script}${html.slice(at)}`
|
||||
): IndexInjection {
|
||||
return { kind: 'script', placement: 'body', text: bootThemeScript(preference) }
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { injectBootTheme } from './boot-theme.ts'
|
||||
import { bootThemeInjection } from './boot-theme.ts'
|
||||
import {
|
||||
DEFAULT_PREFERENCE, THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema,
|
||||
type ThemePreference, type ThemeSettings,
|
||||
@@ -26,18 +26,16 @@ function readPreference(ctx: Context): ThemePreference {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the durable theme section and initial-theme index transform when
|
||||
* their optional Host services are composed.
|
||||
* @param ctx - Host context that may acquire settings and HTTP services.
|
||||
* Register the durable theme section when the optional settings service is
|
||||
* composed, and answer every index injection collection with the current
|
||||
* theme bootstrap row.
|
||||
* @param ctx - Host context that may acquire the settings service.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.inject(['settings'], (settingsCtx) => {
|
||||
settingsCtx.settings.register(THEME_NAMESPACE, ThemeSettingsSchema)
|
||||
})
|
||||
ctx.inject(['webServer'], (httpCtx) => {
|
||||
httpCtx.effect(
|
||||
() => httpCtx.webServer.tapIndex(html => injectBootTheme(html, readPreference(ctx))),
|
||||
'client-ui-theme: initial theme bootstrap',
|
||||
)
|
||||
ctx.on('webserver/index-inject', (table) => {
|
||||
table.push(bootThemeInjection(readPreference(ctx)))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Host index injection and the resulting pre-plugin browser theme. */
|
||||
/** The theme bootstrap injection row and the resulting pre-plugin browser theme. */
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { injectBootTheme } from '../src/boot-theme.ts'
|
||||
import { bootThemeInjection } from '../src/boot-theme.ts'
|
||||
import type { ThemePreference } from '../src/theme-settings.ts'
|
||||
|
||||
const DARK_ATTRIBUTE = 'data-ds-dark-theme'
|
||||
@@ -11,15 +11,10 @@ function mockSystemDark(matches: boolean): void {
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches }) as MediaQueryList))
|
||||
}
|
||||
|
||||
function executeBootstrap(
|
||||
preference?: ThemePreference,
|
||||
html = '<html><body><div id="root"></div><script type="module"></script></body></html>',
|
||||
): string {
|
||||
const injected = injectBootTheme(html, preference)
|
||||
const source = /<script>([\s\S]*?)<\/script>/.exec(injected)?.[1]
|
||||
if (source === undefined) throw new Error('theme bootstrap script missing')
|
||||
runInNewContext(source, { document, matchMedia: globalThis.matchMedia })
|
||||
return injected
|
||||
function executeBootstrap(preference?: ThemePreference): void {
|
||||
const row = bootThemeInjection(preference)
|
||||
if (row.kind !== 'script') throw new Error('theme bootstrap row is not a script')
|
||||
runInNewContext(row.text, { document, matchMedia: globalThis.matchMedia })
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
@@ -29,12 +24,12 @@ afterEach(() => {
|
||||
document.body.removeAttribute(DARK_ATTRIBUTE)
|
||||
})
|
||||
|
||||
describe('theme boot index transform', () => {
|
||||
it('runs immediately inside the body before the shell mount', () => {
|
||||
describe('theme bootstrap row', () => {
|
||||
it('is a body script row, so it runs before the shell mount', () => {
|
||||
mockSystemDark(false)
|
||||
const html = executeBootstrap('dark', '<html><body class="app"><div id="root"></div></body></html>')
|
||||
expect(html.indexOf('<script>')).toBeGreaterThan(html.indexOf('<body class="app">'))
|
||||
expect(html.indexOf('<script>')).toBeLessThan(html.indexOf('<div id="root">'))
|
||||
const row = bootThemeInjection('dark')
|
||||
expect(row).toMatchObject({ kind: 'script', placement: 'body' })
|
||||
executeBootstrap('dark')
|
||||
expect(document.documentElement.style.colorScheme).toBe('dark')
|
||||
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(true)
|
||||
})
|
||||
@@ -63,9 +58,4 @@ describe('theme boot index transform', () => {
|
||||
expect(document.documentElement.style.colorScheme).toBe('light')
|
||||
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
|
||||
})
|
||||
|
||||
it('appends the script to a body-less fragment', () => {
|
||||
const html = injectBootTheme('<main>loading</main>', 'dark')
|
||||
expect(html.startsWith('<main>loading</main><script>')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { WebServer } from '@deepseek-ai/dsh-host-webserver'
|
||||
import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { SettingsProvider, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import {
|
||||
DEFAULT_PREFERENCE, THEME_SETTINGS_NAMESPACE, apply,
|
||||
@@ -14,6 +14,19 @@ class MemorySettings extends SettingsProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/** Collect the injection table the way an index render or boot payload does. */
|
||||
function collect(ctx: Context): IndexInjection[] {
|
||||
const table: IndexInjection[] = []
|
||||
ctx.emit('webserver/index-inject', table)
|
||||
return table
|
||||
}
|
||||
|
||||
/** Narrow the theme row and return its script body. */
|
||||
function scriptText(row: IndexInjection | undefined): string {
|
||||
if (row?.kind !== 'script') throw new Error('expected a script row')
|
||||
return row.text
|
||||
}
|
||||
|
||||
describe('ui-theme host', () => {
|
||||
it('registers, validates, and disposes the durable theme namespace with its fiber', async () => {
|
||||
const ctx = new Context()
|
||||
@@ -29,37 +42,33 @@ describe('ui-theme host', () => {
|
||||
expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns)
|
||||
})
|
||||
|
||||
it('renders the current durable preference and disposes the index transform', async () => {
|
||||
it('answers each collection with the current durable preference until disposal', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(MemorySettings).await()
|
||||
let transform: ((html: string) => string) | undefined
|
||||
let disposed = false
|
||||
ctx.provide('webServer', {
|
||||
tapIndex: (next: (html: string) => string) => {
|
||||
transform = next
|
||||
return () => { disposed = true }
|
||||
},
|
||||
} as WebServer)
|
||||
const fiber = ctx.plugin({ apply })
|
||||
await fiber.await()
|
||||
expect(transform?.('<body></body>')).toContain('const preference = "system"')
|
||||
const rows = collect(ctx)
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]).toMatchObject({ kind: 'script', placement: 'body' })
|
||||
expect(scriptText(rows[0])).toContain('const preference = "system"')
|
||||
await ctx.settings.update(settingsNamespace(THEME_SETTINGS_NAMESPACE), { preference: 'dark' })
|
||||
expect(transform?.('<body></body>')).toContain('const preference = "dark"')
|
||||
expect(scriptText(collect(ctx)[0])).toContain('const preference = "dark"')
|
||||
await fiber.dispose()
|
||||
expect(disposed).toBe(true)
|
||||
expect(transform?.('<body></body>')).toContain('const preference = "system"')
|
||||
expect(collect(ctx)).toEqual([])
|
||||
})
|
||||
|
||||
it('uses the system preference when only an HTTP server exists', async () => {
|
||||
it('uses the system preference without a settings provider', async () => {
|
||||
const ctx = new Context()
|
||||
let transform: ((html: string) => string) | undefined
|
||||
ctx.provide('webServer', {
|
||||
tapIndex: (next: (html: string) => string) => {
|
||||
transform = next
|
||||
return () => undefined
|
||||
},
|
||||
} as WebServer)
|
||||
await ctx.plugin({ apply }).await()
|
||||
expect(transform?.('<body></body>')).toContain('const preference = "system"')
|
||||
expect(scriptText(collect(ctx)[0])).toContain('const preference = "system"')
|
||||
})
|
||||
|
||||
it('falls back to the schema default while the theme namespace holds no section', async () => {
|
||||
// A settings provider whose namespace read comes back empty (registration
|
||||
// still pending or a provider without schema defaults).
|
||||
const ctx = new Context()
|
||||
ctx.provide('settings', { register: () => () => {}, get: () => undefined } as never)
|
||||
await ctx.plugin({ apply }).await()
|
||||
expect(scriptText(collect(ctx)[0])).toContain('const preference = "system"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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/client/ui-user-questions/README.md
|
||||
README.md: ba4b23150a7f3a2536db97df99410cb92238210e
|
||||
README.zh.md: 96c81800ef85514c565defecc779da7d2e6523b7
|
||||
README.md: 4c7c54ad3c9dd5a06f8bd0f5537044479e4ad7e2
|
||||
README.zh.md: 47d654bb7f687819558e8ef228664a815a4b46d0
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Web question feature plugin: its browser half registers the `question` entry in the conversation-owned `conversation.composer` keyed slot. Its host half is empty on purpose — mounting `dsh-tool-ask-user` there put the tool in the registry's GLOBAL layer, which merges into every agent regardless of the preset that composed it, so a two-tool benchmark preset really presented three. Rendering a question is a host UI capability; having the tool is an agent capability, so the `tool-ask-user` row belongs to the presets that want it (and to the TUI composition, which has no presets).
|
||||
|
||||
The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`.
|
||||
The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. Both question shapes answer into a textarea over a hidden height mirror, so a long answer soft-wraps and grows the field in place; growth stops at six lines of text — the same count in both variants — and the field scrolls from there, keeping the choices the answer belongs to in view. Single-select choices advance immediately, Enter continues the flow and submits once every question is answered or skipped, and Shift+Enter breaks a line instead; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`.
|
||||
|
||||
A request whose single question declares a presentation intent renders as that intent's own surface instead. `plan-review` — set by `dsh-plan-mode` on the `exit_plan_mode` review — takes the waiting-approval card shape: a `Plan review` strip, the plan as the scrolling markdown body, the question text as the card's accessible name, and one decision row of `Chat about it` / `Refuse` / `Approve`. Approve and Refuse answer with the asker's own option labels (the intent names which label approves, so the verdict never rides option order) and keep the asker's descriptions as tooltips; `Chat about it` rejects the wait as `ASK_CANCELLED`, returning the composer so the user can say what they want instead. The card claims a request only when it can send every answer that request allows: one question, the intent declared, the plan present as `detail`, the named approve label offered, and a binary single choice (at most one option besides approve, not multi-select). Anything else — no intent, a batch of several questions, a missing plan, an approve label naming no option, a third option, a multi-select decision — stays on the generic flow, which can express it. An intent changes the layout, never which answers are reachable.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Web 提问功能插件:其浏览器侧把 `question` 条目注册到会话拥有的 `conversation.composer` 键控 slot 中。其主机侧刻意为空——在那里挂载 `dsh-tool-ask-user` 会把工具放进注册表的**全局层**,而全局层会并入每一个 agent(智能体),无论它由哪个 preset 组装,于是一个「两工具」的 benchmark preset 实际会呈现三个。渲染提问是宿主的 UI 能力,拥有该工具则是 agent 的能力,因此 `tool-ask-user` 行属于需要它的各个 preset(以及没有 preset 的 TUI 组装)。
|
||||
|
||||
组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。选择单选选项后会立即前进;所有问题均已回答或跳过后,Enter 会提交;IME 组合输入期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。
|
||||
组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。两种问题形状的自定义答案都写入一个带隐藏高度镜像的 textarea,因此长答案会软换行并就地把输入框撑高;增高到六行文本为止——两种形状行数相同——此后由输入框自身滚动,使答案所属的选项仍留在视野内。选择单选选项后会立即前进;Enter 继续流程,所有问题均已回答或跳过后即提交,Shift+Enter 则改为换行;IME 组合输入期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。
|
||||
|
||||
若某个请求的唯一问题声明了呈现意图,则改为渲染该意图自己的界面。`plan-review`——由 `dsh-plan-mode` 在 `exit_plan_mode` 审阅上设置——采用等待审批卡片的形状:一条 `Plan review` 条带、计划作为可滚动的 markdown 主体、问题文本作为卡片的无障碍名称,以及一行 `Chat about it` / `Refuse` / `Approve` 的决定操作。Approve 与 Refuse 用提问方自己的选项标签回答(意图指名哪个标签表示批准,因此裁决绝不依赖选项顺序),并把提问方的描述保留为 tooltip;`Chat about it` 以 `ASK_CANCELLED` 拒绝该等待,让编辑器归位,用户可以直接说出他想说的话。卡片只在能够发出该请求允许的每一个答案时才接管:只有一个问题、声明了意图、计划以 `detail` 存在、提供了被指名的批准标签,且是二元单选(除批准外最多一个选项,且非多选)。其他任何情形——没有意图、一批含多个问题、缺少计划、批准标签未命中任何选项、出现第三个选项、多选决定——都留在能够表达它的通用流程上。意图改变的只是布局,从不改变可达的答案。
|
||||
|
||||
|
||||
@@ -321,54 +321,83 @@
|
||||
border-color: var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.customInput {
|
||||
flex: 1;
|
||||
/* Auto-growing free-text answer (see AnswerField): mirror and textarea share
|
||||
one grid cell, the mirror sizes it, and the textarea stretches to it. The
|
||||
two layers MUST keep identical type, padding and wrapping — a mismatch
|
||||
makes the box the wrong height for the text the user is typing. */
|
||||
.field {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
--dsh-answer-field-padding: 0;
|
||||
}
|
||||
|
||||
.field > * {
|
||||
grid-area: 1 / 1;
|
||||
min-width: 0;
|
||||
padding: var(--dsh-answer-field-padding);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* The height ruler, never painted. Growth stops at 6 lines: the card body is
|
||||
capped at 520px and still owes that budget to the title, the option rows and
|
||||
the footer actions, so a long answer scrolls in the textarea instead of
|
||||
pushing the choices it belongs to out of view.
|
||||
content-box overrides the card-wide border-box so the cap counts TEXT lines
|
||||
in both variants: under border-box the block variant's 16px of vertical
|
||||
padding eats two thirds of a line and the sixth one arrives as an 8px sliver.
|
||||
Width is unaffected — a stretched grid item's used width comes from the
|
||||
track, not from this box-sizing. */
|
||||
.fieldMirror {
|
||||
box-sizing: content-box;
|
||||
max-height: 144px;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.fieldInput {
|
||||
resize: none;
|
||||
/* The stack's only scrollport: past the mirror's cap the field stops growing
|
||||
and this scrolls. */
|
||||
overflow-y: auto;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
caret-color: var(--dsw-alias-state-business-primary);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.customInput::placeholder {
|
||||
.fieldInput::placeholder {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
/* Optionless question: the free-form answer is the whole body. The 12px side
|
||||
margins add to the .options 12px padding so both edges align with the
|
||||
title's 24px inset; type matches the option rows, no resize handle. */
|
||||
.customTextarea {
|
||||
display: block;
|
||||
/* Inline variant: the custom row's copy column, borrowing the row's chrome. */
|
||||
.customInline {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Optionless question: the free-form answer is the whole body, so it carries
|
||||
its own frame. The 12px side margins add to the .options 12px padding so both
|
||||
edges align with the title's 24px inset; two lines are reserved before the
|
||||
answer starts growing. */
|
||||
.customBlock {
|
||||
min-height: 64px;
|
||||
max-height: 140px;
|
||||
flex-shrink: 0;
|
||||
margin: 0 12px;
|
||||
padding: 8px 12px;
|
||||
resize: none;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 10px;
|
||||
outline: none;
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
caret-color: var(--dsw-alias-state-business-primary);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
--dsh-answer-field-padding: 8px 12px;
|
||||
}
|
||||
|
||||
.customTextarea:focus {
|
||||
.customBlock:focus-within {
|
||||
border-color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.customTextarea::placeholder {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -39,12 +39,67 @@ export function parseRecommendedLabel(label: string): { label: string; recommend
|
||||
}
|
||||
|
||||
/** Return whether a text-field key event belongs to an active IME composition. */
|
||||
function isComposing(event: KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>): boolean {
|
||||
function isComposing(event: KeyboardEvent<HTMLTextAreaElement>): boolean {
|
||||
// keyCode 229 is the legacy IME-composition signal engines emit without isComposing.
|
||||
// oxlint-disable-next-line typescript/no-deprecated
|
||||
return event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229
|
||||
}
|
||||
|
||||
/** The free-text answer field shared by both question shapes. */
|
||||
interface AnswerFieldProps {
|
||||
/** Which shape the field takes: the custom row's inline column, or the optionless question's own framed block. */
|
||||
variant: 'inline' | 'block'
|
||||
/** Current draft text. */
|
||||
value: string
|
||||
/** Empty-field prompt. */
|
||||
placeholder: string
|
||||
/** Whether a submission in flight has frozen the field. */
|
||||
disabled: boolean
|
||||
/** Whether this field takes focus on mount. */
|
||||
autoFocus?: boolean
|
||||
/** Called when the field takes focus. */
|
||||
onFocus?: () => void
|
||||
/** Called with each edit of the draft. */
|
||||
onChange: (event: ChangeEvent<HTMLTextAreaElement>) => void
|
||||
/** Called with each key press, before the browser's own handling. */
|
||||
onKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-growing free-text answer: a textarea, so a long answer soft-wraps and
|
||||
* Shift+Enter breaks a line, over a hidden mirror that owns the height.
|
||||
*
|
||||
* The mirror renders the draft plus a trailing newline in normal flow and so
|
||||
* sizes the grid row (counting rows by '\n' cannot see soft wraps); the
|
||||
* textarea shares that one cell and stretches to it, and `rows={1}` keeps the
|
||||
* control's own intrinsic height out of the row sizing so the mirror alone
|
||||
* decides. Past the mirror's cap the textarea scrolls itself — it is the only
|
||||
* scrollport in the stack, there being no second glyph layer to keep aligned.
|
||||
* Mirror and textarea MUST share font, line-height, padding and wrapping rules
|
||||
* or the two heights diverge.
|
||||
*
|
||||
* @param props - field shape, draft text, and the field's event handlers.
|
||||
* @returns The mirrored auto-growing field.
|
||||
*/
|
||||
function AnswerField(props: AnswerFieldProps) {
|
||||
return (
|
||||
<div className={clsx(css.field, props.variant === 'inline' ? css.customInline : css.customBlock)}>
|
||||
<div aria-hidden className={css.fieldMirror}>{`${props.value}\n`}</div>
|
||||
<textarea
|
||||
autoFocus={props.autoFocus}
|
||||
className={css.fieldInput}
|
||||
value={props.value}
|
||||
disabled={props.disabled}
|
||||
rows={1}
|
||||
placeholder={props.placeholder}
|
||||
onFocus={props.onFocus}
|
||||
onChange={props.onChange}
|
||||
onKeyDown={props.onKeyDown}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Composer takeover boundary; the carrier key keys local drafts, so a
|
||||
* same-request replay (same key, new carrier object) preserves them.
|
||||
@@ -164,11 +219,10 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
|
||||
submitDrafts(drafts)
|
||||
}
|
||||
|
||||
// Shared by the inline custom input and the optionless textarea: a
|
||||
// multi-select draft retains checked labels, while a single-select custom
|
||||
// answer replaces its selection. Enter continues the flow (Shift+Enter
|
||||
// stays a newline in the textarea; on the single-line input it is inert).
|
||||
const draftCustom = (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
|
||||
// Shared by the inline custom field and the optionless one: a multi-select
|
||||
// draft retains checked labels, while a single-select custom answer replaces
|
||||
// its selection. Enter continues the flow, Shift+Enter breaks a line.
|
||||
const draftCustom = (event: ChangeEvent<HTMLTextAreaElement>): void => {
|
||||
const value = event.target.value
|
||||
updateDraft(current => ({
|
||||
...current,
|
||||
@@ -178,7 +232,7 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
|
||||
}))
|
||||
}
|
||||
|
||||
const continueFromCustom = (event: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
|
||||
const continueFromCustom = (event: KeyboardEvent<HTMLTextAreaElement>): void => {
|
||||
if (event.key !== 'Enter' || event.shiftKey || isComposing(event)) return
|
||||
event.preventDefault()
|
||||
continueFlow()
|
||||
@@ -295,9 +349,8 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
|
||||
<IconEditOutline16 size={12} />
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
className={css.customInput}
|
||||
<AnswerField
|
||||
variant="inline"
|
||||
value={draft.custom}
|
||||
disabled={busy !== null}
|
||||
placeholder={t('custom.placeholder')}
|
||||
@@ -307,12 +360,11 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<textarea
|
||||
<AnswerField
|
||||
autoFocus={!focusedQuestions.current.has(index)}
|
||||
className={css.customTextarea}
|
||||
variant="block"
|
||||
value={draft.custom}
|
||||
disabled={busy !== null}
|
||||
rows={2}
|
||||
placeholder={t('custom.placeholder')}
|
||||
onFocus={() => { focusedQuestions.current.add(index) }}
|
||||
onChange={draftCustom}
|
||||
|
||||
@@ -204,6 +204,43 @@ describe('QuestionComposer', () => {
|
||||
expect(respond).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('answers over multiple lines: both fields grow with the draft and keep Shift+Enter a newline', () => {
|
||||
const { carrier, respond } = wait()
|
||||
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
|
||||
|
||||
// Both question shapes answer into a textarea, so the engine soft-wraps a
|
||||
// long answer and Shift+Enter breaks the line natively.
|
||||
const inline = screen.getByPlaceholderText('输入你的答案')
|
||||
expect(inline.tagName).toBe('TEXTAREA')
|
||||
|
||||
const multiline = '第一行\n第二行'
|
||||
fireEvent.change(inline, { target: { value: multiline } })
|
||||
// The hidden height ruler carries the draft plus the trailing newline the
|
||||
// textarea's own last line needs, so the box is as tall as the answer.
|
||||
expect(inline.previousElementSibling?.textContent).toBe(`${multiline}\n`)
|
||||
// Shift+Enter belongs to the field, never to the flow.
|
||||
fireEvent.keyDown(inline, { key: 'Enter', shiftKey: true })
|
||||
expect(screen.getByText('1 / 3')).toBeTruthy()
|
||||
|
||||
fireEvent.keyDown(inline, { key: 'Enter' })
|
||||
const optionless = screen.getByPlaceholderText('输入你的答案')
|
||||
expect(optionless.tagName).toBe('TEXTAREA')
|
||||
fireEvent.change(optionless, { target: { value: multiline } })
|
||||
expect(optionless.previousElementSibling?.textContent).toBe(`${multiline}\n`)
|
||||
fireEvent.keyDown(optionless, { key: 'Enter', shiftKey: true })
|
||||
expect(screen.getByText('2 / 3')).toBeTruthy()
|
||||
|
||||
fireEvent.keyDown(optionless, { key: 'Enter' })
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交' }))
|
||||
// Line breaks reach the model verbatim: nothing along the way flattens them.
|
||||
expect(respond).toHaveBeenCalledWith(answeredEnvelope('question-1', [
|
||||
{ id: 'profile', selected: [], custom: multiline },
|
||||
{ id: 'detail', selected: [], custom: multiline },
|
||||
{ id: 'signals', selected: ['系统设计'] },
|
||||
]))
|
||||
})
|
||||
|
||||
it('surfaces cancellation failures: rejected receipt text and raw transport reasons', async () => {
|
||||
const respond = vi.fn()
|
||||
.mockResolvedValueOnce({ accepted: false, reason: 'bad-response' })
|
||||
|
||||
@@ -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/client/web/README.md
|
||||
README.md: c7b77d3eba8cf56017c816362e9ffc4fcb3be403
|
||||
README.zh.md: 2a52bcdfe7395466866b80160f0646cf3b7fe111
|
||||
README.md: b4304ddc3f7d8fc0f8bfc5cb8373196a97e0273f
|
||||
README.zh.md: aebaccf819f02ed49afdf4f20027738c19c6cde0
|
||||
|
||||
@@ -8,7 +8,7 @@ The boot page uses plain DOM and local CSS, so client-bundle and plugin-activati
|
||||
|
||||
`PLATFORM_MODULES` (src/platform.ts) is the single source of truth for shell-seeded shared modules. Together with `PRELOADED_CLIENT_EXTERNALS`, it defines the implicit external baseline for every dynamic bundle; `dsh.client.external` adds only exact non-baseline requests.
|
||||
|
||||
The optional override parameter `seams` forwards the module system's `loadBundle` transport override (`BootSeams`) for environments where external `<script>` execution cannot reach the page context; ordinary browser callers omit it.
|
||||
The optional override parameter `seams` forwards the module system's `loadBundle` transport override (`BootSeams`) for environments where external `<script>` execution cannot reach the page context; ordinary browser callers omit it. A pre-injected page transport is the default ahead of it: when `globalThis.__DSH_TRANSPORT__` (the connection package's `ClientTransportHooks`) carries `loadBundle`, the module stage adopts it as the bundle transport and skips the immediate-tier HTTP prefetch — explicit `seams` still win.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ Web 启动内核:`new AppWebEntry(el, seams?).run()` 分两个阶段挂载客
|
||||
|
||||
`PLATFORM_MODULES`(src/platform.ts)是外壳播种共享模块的唯一事实来源。它与 `PRELOADED_CLIENT_EXTERNALS` 一起定义全部动态 bundle 的隐式 external 基座;`dsh.client.external` 只添加基座之外的精确请求。
|
||||
|
||||
可选的覆盖参数 `seams` 会为外部 `<script>` 执行无法到达页面上下文的环境转发模块系统的 `loadBundle` 传输覆盖(`BootSeams`);普通浏览器调用方省略此参数。
|
||||
可选的覆盖参数 `seams` 会为外部 `<script>` 执行无法到达页面上下文的环境转发模块系统的 `loadBundle` 传输覆盖(`BootSeams`);普通浏览器调用方省略此参数。预注入的页面传输是位于其前的默认值:当 `globalThis.__DSH_TRANSPORT__`(connection 包的 `ClientTransportHooks`)携带 `loadBundle` 时,模块阶段将其采纳为 bundle 传输并跳过 immediately 层级的 HTTP 预取——显式 `seams` 仍然优先。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -50,9 +50,18 @@ export class AppWebEntry {
|
||||
if (moduleLoader === undefined) {
|
||||
throw new Error('web boot: window.__ModuleLoader__ bootstrap facade is missing')
|
||||
}
|
||||
// A pre-injected transport (the worker preview page) owns bundle bytes;
|
||||
// its loadBundle is the default and explicit seams still win. The global
|
||||
// is `ClientTransportHooks`, owned by @deepseek-ai/dsh-client-connection;
|
||||
// this structural slice reads one optional member without adding a
|
||||
// package edge.
|
||||
const transport = (globalThis as {
|
||||
__DSH_TRANSPORT__?: { loadBundle?: ClientModuleCreateOptions['loadBundle'] }
|
||||
}).__DSH_TRANSPORT__
|
||||
this.modules = moduleLoader.create({
|
||||
boot: win.__DSH_BOOT__,
|
||||
staticModules: getStaticModules(),
|
||||
...transport?.loadBundle === undefined ? {} : { loadBundle: transport.loadBundle },
|
||||
...this.seams,
|
||||
})
|
||||
this.manifest = this.modules.manifest
|
||||
@@ -86,6 +95,13 @@ export class AppWebEntry {
|
||||
|
||||
/** Prefetch stage-one bundles; their import path owns any eventual failure. */
|
||||
private async prefetchImmediateTier(): Promise<void> {
|
||||
// A transport carrying loadBundle owns the bundle bytes; HTTP prefetch
|
||||
// against its static deployment answers nothing. A transport without
|
||||
// loadBundle leaves bundles on HTTP, prefetch included.
|
||||
const transport = (globalThis as {
|
||||
__DSH_TRANSPORT__?: { loadBundle?: unknown }
|
||||
}).__DSH_TRANSPORT__
|
||||
if (transport?.loadBundle !== undefined) return
|
||||
await Promise.all(this.manifest.plugins
|
||||
.filter(row => row.immediately)
|
||||
.map(row => this.modules.prefetch(row.id).catch((_prefetchError: unknown) => {
|
||||
|
||||
@@ -196,23 +196,50 @@ export function parseErrorMessage(half: 'code.host' | 'code.client', context: st
|
||||
/**
|
||||
* Parse one half's source without running it: the define-time precheck that
|
||||
* keeps unparseable code out of the registry, so a model fixes it and defines
|
||||
* again instead of discovering the failure at run time. Compiling through `vm`
|
||||
* rather than `new Function` is what makes the two agree — same wrapper, same
|
||||
* compiler, and the same source-line-and-caret prelude in the failure.
|
||||
* again instead of discovering the failure at run time. `new Function` is the
|
||||
* gate — hosts without a real `node:vm` (the browser worker) still refuse
|
||||
* unparseable code — and `vm.Script` is only the best-effort prettifier: on a
|
||||
* Node host its failure carries the source-line-and-caret prelude the
|
||||
* teaching text builds on, and where the vm is a stub the message stays bare.
|
||||
* The two parsers' syntax faces differ at the margin (`new.target` parses in
|
||||
* a function body but not at the vm wrapper's top level), an accepted cost of
|
||||
* a vm-free gate; and under a page CSP without `'unsafe-eval'`, `new Function`
|
||||
* throws `EvalError`, which propagates unwrapped.
|
||||
* @param code - the model-written function body.
|
||||
* @param half - which define argument carried it, for the error text.
|
||||
* @throws when the body does not parse, with the offending line and a teaching hint.
|
||||
*/
|
||||
export function precheckCode(code: string, half: 'code.host' | 'code.client'): void {
|
||||
const wrapped = `(async () => {\n${code}\n})()`
|
||||
try {
|
||||
// Compile-only: constructing the Script parses the source and runs nothing.
|
||||
new Script(`(async () => {\n${code}\n})()`, { filename: `cordis-dyn-${half}.js` })
|
||||
// Compile-only: constructing the function parses the source and runs nothing.
|
||||
// oxlint-disable-next-line typescript/no-implied-eval -- parse gate over model-written code; nothing is invoked
|
||||
new Function(wrapped)
|
||||
} catch (error) {
|
||||
if (!isSyntaxError(error)) throw error
|
||||
throw new Error(parseErrorMessage(half, syntaxErrorContext(error)))
|
||||
throw new Error(parseErrorMessage(half, prettyParseContext(wrapped, half, error)))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort vm recompile of a body `new Function` already refused, for the
|
||||
* source-line-and-caret prelude only.
|
||||
* @param wrapped - the wrapped source that failed to parse.
|
||||
* @param half - which define argument carried it, for the vm filename.
|
||||
* @param refusal - the gate's own `SyntaxError`, the fallback context source.
|
||||
* @returns the vm prelude when a real vm produced one, else the bare refusal.
|
||||
*/
|
||||
function prettyParseContext(wrapped: string, half: 'code.host' | 'code.client', refusal: Error): string {
|
||||
try {
|
||||
new Script(wrapped, { filename: `cordis-dyn-${half}.js` })
|
||||
} catch (vmError) {
|
||||
if (isSyntaxError(vmError)) return syntaxErrorContext(vmError)
|
||||
// A stubbed vm (the browser worker) refuses Script itself; the gate's
|
||||
// error is the only context there is.
|
||||
}
|
||||
return String(refusal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a host half as the body of an async function inside the sandbox. `vmTimeoutMs` only
|
||||
* bounds the SYNCHRONOUS portion; an async body escapes it — acceptable under the module's
|
||||
|
||||
@@ -459,8 +459,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
key: 'clientModules',
|
||||
summary: 'The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index tap.',
|
||||
description: 'The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index tap. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).',
|
||||
summary: 'The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index injection rows.',
|
||||
description: 'The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index injection rows. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).',
|
||||
methods: [
|
||||
{
|
||||
signature: 'graph(): WebBootGraph',
|
||||
@@ -2207,7 +2207,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
signature: 'tapIndex(transform: (html: string) => string): () => void',
|
||||
description: 'Register an index.html transform, applied by the fallback owner to every index response (applyIndexTaps) in registration order.',
|
||||
description: 'Register a raw-HTML index transform, the escape hatch for markup no IndexInjection row expresses: renderIndex applies taps in registration order after rendering the structured rows.',
|
||||
parameters: [{ name: 'transform', description: 'pure html-to-html function.' }],
|
||||
returns: 'the disposer removing the transform.',
|
||||
},
|
||||
@@ -2217,6 +2217,18 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
parameters: [{ name: 'html', description: 'the raw index.html body.' }],
|
||||
returns: 'the transformed body.',
|
||||
},
|
||||
{
|
||||
signature: 'collectIndexInjections(): IndexInjection[]',
|
||||
description: 'Gather the structured injection table: one `webserver/index-inject` emit, every subscriber pushes its current rows. Fresh per call, so subscribers read live state (module graph, theme preference) at emit time.',
|
||||
parameters: [],
|
||||
returns: 'rows in subscriber activation order.',
|
||||
},
|
||||
{
|
||||
signature: 'renderIndex(html: string): string',
|
||||
description: 'Render one index.html body: the structured injection table first, then the raw `tapIndex` transforms over the result.',
|
||||
parameters: [{ name: 'html', description: 'the raw index.html body.' }],
|
||||
returns: 'the transformed body.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -2685,6 +2697,14 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
description: 'Observe the frozen, lossless-JSON final outcome. Listener failures are contained. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`.',
|
||||
parameters: [{ name: 'exec', description: 'the execution object that traversed the pipeline.' }, { name: 'result', description: 'a deep-frozen snapshot of the final returned result.' }],
|
||||
},
|
||||
{
|
||||
name: 'webserver/index-inject',
|
||||
mode: 'emit',
|
||||
signature: '\'webserver/index-inject\'(table: IndexInjection[]): void',
|
||||
summary: 'Collect the structured index injection table.',
|
||||
description: 'Collect the structured index injection table. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row\'s data is read fresh at emit time.',
|
||||
parameters: [{ name: 'table', description: 'Mutable row table; listeners append in activation order.' }],
|
||||
},
|
||||
{
|
||||
name: 'workflow/agent-end',
|
||||
mode: 'emit',
|
||||
@@ -3297,6 +3317,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'InboxTarget',
|
||||
declaration: 'export type InboxTarget = \'next-turn\' | \'next-step\';',
|
||||
},
|
||||
{
|
||||
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};',
|
||||
},
|
||||
{
|
||||
name: 'IndexInjectionPlacement',
|
||||
declaration: 'export type IndexInjectionPlacement = \'head\' | \'body\';',
|
||||
},
|
||||
{
|
||||
name: 'InvariantFailure',
|
||||
declaration: 'export type InvariantFailure = (message: string) => never;',
|
||||
|
||||
@@ -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/apiproxy/README.md
|
||||
README.md: 27efe5a75eb6947d71f95c0a60e590c15a35887c
|
||||
README.zh.md: 71f4e63a013170c6822a5c8dae28bc5f899b271a
|
||||
README.md: a9fb07519c1d8b3b2e2d759bd9138dac60f2306f
|
||||
README.zh.md: 9bde3583b0f5e6ce172bfe145f440150aabfa230
|
||||
|
||||
@@ -76,6 +76,7 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
- **Forwarded Remote events are parasitic on this legacy frame union** — `host/remote-event` lives in `HostFrame` so the delivery path could reuse the existing host stream instead of opening a third downlink, which makes it read as if this package owned the Remote event contract. It does not: the allowlist is `dsh-api-remotes`' and the consumer verb is `ctx.remote.$on`. When the host stream moves off this package, the frame moves with it and the consumer contract is unaffected ([rationale](../../../.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md)).
|
||||
- **Pending-interaction state is host-side** — the wire uses POST `/api/respond` plus `RpcReceipt`; the table in `src/api-proxy.ts` handles questions only and has no approval entries.
|
||||
- **A pending question does not survive a host restart** — the registry holds the awaiting tool call's own `resolve`/`reject`, so it is host-process memory. `events.mux` replays every still-pending question on each reopen, which covers a browser reload or a reconnect; a host restart takes the awaiting turn with it, and the reopened Session offers no composer for that question. Restoring one across a host restart needs a durable pending-interaction record and is deferred.
|
||||
- **Reserved seams stay out of `RpcMethodMap`** — `prompt.mode: 'inject'`, `job.list`, and a describe `hostInstanceId` are documented reservations; model discovery uses `llm.models`. An unknown method fails loud at envelope parse rather than getting a not-implemented code.
|
||||
- **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists.
|
||||
- **Search failures include provider diagnostics** — the gateway is a single-user local service. A carrier that exposes it to multiple users must replace internal search details with a public-safe diagnostic.
|
||||
|
||||
@@ -76,6 +76,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
|
||||
|
||||
- **转发的 Remote 事件寄居在这套 legacy 帧联合里**:`host/remote-event` 住在 `HostFrame` 中,是为了让投递路径复用现有宿主流、不必新开第三条下行通道,因此读起来像是本包拥有 Remote 事件契约。并非如此:名单归 `dsh-api-remotes`,消费端动词是 `ctx.remote.$on`。将来宿主流整体搬离本包时,该帧随之搬走,消费端契约不受影响([原委](../../../.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md))。
|
||||
- **待处理交互状态位于宿主侧**:wire 使用 POST `/api/respond` 加 `RpcReceipt`;`src/api-proxy.ts` 中的表只处理问题,不包含审批条目。
|
||||
- **待回答的提问无法跨宿主重启存活**:登记表持有等待中那次工具调用自身的 `resolve`/`reject`,因此它是宿主进程内存。`events.mux` 在每次重开时重放所有仍在等待的提问,这覆盖了浏览器刷新与重连;宿主重启则把等待中的 turn 一并带走,重新打开的 Session 不会为该提问提供任何作答界面。要让它跨宿主重启恢复,需要一份持久化的待处理交互记录,此项暂缓。
|
||||
- **预留 seam 不进入 `RpcMethodMap`**:`prompt.mode: 'inject'`、`job.list` 和描述字段 `hostInstanceId` 都是已记录的预留项;模型发现使用 `llm.models`。未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。
|
||||
- **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。
|
||||
- **搜索失败会包含提供方诊断信息**:网关是单用户本地服务。将其暴露给多名用户的载体必须用可安全公开的诊断信息替代内部搜索细节。
|
||||
|
||||
@@ -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/frontend-static/README.md
|
||||
README.md: e53a369384fb8d7fe020e99da0d2cacca59e8afd
|
||||
README.zh.md: 7018d80182d477f2bdff7b1d8510971dc98c71d5
|
||||
README.md: fb9ab0c9fea6fdaffdc08ecb63b17ffff9ff1e16
|
||||
README.zh.md: b0fd0f3aac7e54ccd50f8e87a186fd080c925610
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
SPA dist server for the Web shell: a function plugin (config `{distIndex}`) that claims the [webserver](../webserver/README.md)'s single fallback seat and serves the built frontend directory with the shell's locked semantics — traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as `application/octet-stream`, and non-GET/HEAD without a matching named route is 405. Every index response runs through the webserver's registered index taps (`applyIndexTaps`), which is how the boot manifest reaches the page. `distIndex` is an assembly fact of the composing application: [`dsh-web-app`](../../bundle/web-app/README.md) resolves it through the frontend package's exports and mounts this plugin; a deployment never hardcodes it.
|
||||
SPA dist server for the Web shell: a function plugin (config `{distIndex}`) that claims the [webserver](../webserver/README.md)'s single fallback seat and serves the built frontend directory with the shell's locked semantics — traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as `application/octet-stream`, and non-GET/HEAD without a matching named route is 405. Every index response is rendered through the webserver's `renderIndex` — structured injection rows first, then the raw index taps — which is how the boot manifest reaches the page. `distIndex` is an assembly fact of the composing application: [`dsh-web-app`](../../bundle/web-app/README.md) resolves it through the frontend package's exports and mounts this plugin; a deployment never hardcodes it.
|
||||
|
||||
The fallback seat is single-owner (a second claim throws) and effect-scoped: disposing the plugin's fiber releases the seat, after which the unclaimed webserver answers 404.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Web 壳的 SPA dist 服务器:一个函数插件(配置为 `{distIndex}`),占据 [webserver](../webserver/README.md) 的唯一回退席位,并按壳层锁定的语义服务已构建的前端目录——越出 dist 根目录的遍历返回 403,任何未命中项都以 HTTP 200 回退到 `index.html`(SPA 路由),未知扩展名按 `application/octet-stream` 提供,GET/HEAD 之外的方法在没有匹配的具名路由时返回 405。每个 index 响应都会经过 webserver 已注册的 index 转换器(`applyIndexTaps`),启动 manifest(元数据清单)就是经这条路径送达页面的。`distIndex` 是组合应用的组装事实:[`dsh-web-app`](../../bundle/web-app/README.md) 通过前端包的 exports 解析它并挂载本插件;部署绝不硬编码它。
|
||||
Web 壳的 SPA dist 服务器:一个函数插件(配置为 `{distIndex}`),占据 [webserver](../webserver/README.md) 的唯一回退席位,并按壳层锁定的语义服务已构建的前端目录——越出 dist 根目录的遍历返回 403,任何未命中项都以 HTTP 200 回退到 `index.html`(SPA 路由),未知扩展名按 `application/octet-stream` 提供,GET/HEAD 之外的方法在没有匹配的具名路由时返回 405。每个 index 响应都经 webserver 的 `renderIndex` 渲染——先结构化注入行、后原始 index 转换器——启动 manifest(元数据清单)就是经这条路径送达页面的。`distIndex` 是组合应用的组装事实:[`dsh-web-app`](../../bundle/web-app/README.md) 通过前端包的 exports 解析它并挂载本插件;部署绝不硬编码它。
|
||||
|
||||
回退席位只有单一所有者(第二次占据会抛错),并受 effect 作用域约束:dispose(资源释放)插件的 fiber 会释放席位,此后无人占据的 webserver 回答 404。
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
* Web shell locked at step1 — traversal outside the dist root is 403, any
|
||||
* miss falls back to index.html with HTTP 200 (SPA routing), unknown
|
||||
* extensions ship as octet-stream, non-GET/HEAD is 405. Every index response
|
||||
* runs through the webserver's registered index taps (boot-manifest
|
||||
* injection). The dist location is workspace knowledge of the composing
|
||||
* runs through the webserver's index render (structured injection rows, then
|
||||
* raw taps). The dist location is workspace knowledge of the composing
|
||||
* application, so `distIndex` is typically supplied through a `!!js`
|
||||
* expression, never hardcoded by a deployment.
|
||||
* @module @deepseek-ai/dsh-host-frontend-static
|
||||
@@ -50,7 +50,7 @@ const MIME: Record<string, string> = {
|
||||
* @param res - the node:http response to write.
|
||||
* @param distRoot - absolute dist root directory (resolved by the caller).
|
||||
* @param distIndex - absolute path of index.html inside distRoot.
|
||||
* @param renderIndex - produces the index.html body (index-tap injection) for
|
||||
* @param renderIndex - produces the index.html body (injection rendering) for
|
||||
* `/` and every SPA fallback.
|
||||
*/
|
||||
export async function serveStatic(
|
||||
@@ -94,7 +94,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const distIndex = config.distIndex
|
||||
const distRoot = dirname(distIndex)
|
||||
const renderIndex = async (): Promise<string> =>
|
||||
ctx.webServer.applyIndexTaps(await readFile(distIndex, 'utf8'))
|
||||
ctx.webServer.renderIndex(await readFile(distIndex, 'utf8'))
|
||||
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).
|
||||
|
||||
@@ -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: 7927c791a809ad272edce1310b3c50bbe30720a5
|
||||
README.zh.md: 82252adb3945f22c83abddbd3bbdd11556383add
|
||||
README.md: b6424262305b062f4c57774c446ac1d50e490a03
|
||||
README.zh.md: cdeeb26159b98048f8ba5e821e7d6e733f447351
|
||||
|
||||
@@ -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. `tapIndex(transform)` adds an index.html transform, and `applyIndexTaps(html)` runs a body through the registered transforms in order; the fallback handler calls it on every index response. `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); 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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -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.md) 是该 handler 的所有者,没有注册 handler 时服务器返回 404。`tapIndex(transform)` 添加一个 index.html 转换,`applyIndexTaps(html)` 按注册顺序对一段响应体运行已注册的转换;fallback handler 在每次 index 响应时调用它。`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.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 只做精确匹配,未命中连接直接关闭;注册顺序不影响请求处理。
|
||||
|
||||
该包不了解任何 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。
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* @deepseek-ai/dsh-host-webserver — Web route-registration plugin: a node:http
|
||||
* server plus the `webServer` service (HTTP and upgrade route registries,
|
||||
* index transform taps, and the single fallback seat for everything no route
|
||||
* claims). Knows no harness concepts and serves no files; the composing
|
||||
* server plus the `webServer` service (HTTP and upgrade route registries, the
|
||||
* structured index injection table with raw transform taps behind it, and the
|
||||
* single fallback seat for everything no route claims). Knows no harness concepts and serves no files; the composing
|
||||
* application's frontend plugin owns dist serving through the fallback hook.
|
||||
* Web shape only — Electron loads dist over file:// and carries fetch over an
|
||||
* IPC bridge. This package never prints: the URL line belongs to the shell.
|
||||
@@ -14,11 +14,25 @@ import type { AddressInfo } from 'node:net'
|
||||
import type { Duplex } from 'node:stream'
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { renderIndexInjections, type IndexInjection } from './injections.ts'
|
||||
|
||||
export { renderIndexInjections } from './injections.ts'
|
||||
export type { IndexInjection, IndexInjectionPlacement } from './injections.ts'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
webServer: WebServer
|
||||
}
|
||||
interface Events {
|
||||
/**
|
||||
* Collect the structured index injection table. Emitted on every index
|
||||
* render and every worker boot-payload request; listeners push their
|
||||
* current rows, so a row's data is read fresh at emit time.
|
||||
* @param table - Mutable row table; listeners append in activation order.
|
||||
* @mode emit
|
||||
*/
|
||||
'webserver/index-inject'(table: IndexInjection[]): void
|
||||
}
|
||||
}
|
||||
|
||||
/** Route match kind: 'exact' matches the pathname verbatim; 'prefix' p matches p and p/<anything>. */
|
||||
@@ -131,8 +145,9 @@ export class WebServer extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an index.html transform, applied by the fallback owner to every
|
||||
* index response ({@link applyIndexTaps}) in registration order.
|
||||
* Register a raw-HTML index transform, the escape hatch for markup no
|
||||
* {@link IndexInjection} row expresses: {@link renderIndex} applies taps in
|
||||
* registration order after rendering the structured rows.
|
||||
* @param transform - pure html-to-html function.
|
||||
* @returns the disposer removing the transform.
|
||||
*/
|
||||
@@ -261,6 +276,28 @@ export class WebServer extends Service {
|
||||
for (const transform of this.indexTaps) out = transform(out)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Gather the structured injection table: one `webserver/index-inject` emit,
|
||||
* every subscriber pushes its current rows. Fresh per call, so subscribers
|
||||
* read live state (module graph, theme preference) at emit time.
|
||||
* @returns rows in subscriber activation order.
|
||||
*/
|
||||
collectIndexInjections(): IndexInjection[] {
|
||||
const table: IndexInjection[] = []
|
||||
this.ctx.emit('webserver/index-inject', table)
|
||||
return table
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one index.html body: the structured injection table first, then
|
||||
* the raw `tapIndex` transforms over the result.
|
||||
* @param html - the raw index.html body.
|
||||
* @returns the transformed body.
|
||||
*/
|
||||
renderIndex(html: string): string {
|
||||
return this.applyIndexTaps(renderIndexInjections(html, this.collectIndexInjections()))
|
||||
}
|
||||
}
|
||||
|
||||
export default WebServer
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Structured index injections: the typed rows plugins contribute to the boot
|
||||
* HTML instead of raw `tapIndex` string transforms. Rows are pure
|
||||
* JSON-serializable data because one table feeds two renderers: the served
|
||||
* form renders rows into the index.html text ({@link renderIndexInjections}),
|
||||
* and a static worker deployment ships the same rows over its boot payload
|
||||
* for a page-side interpreter. Anything not expressible as a row stays on
|
||||
* `tapIndex`, which runs after row rendering.
|
||||
*/
|
||||
|
||||
/** Document region a rendered row lands in: after the opening head or body tag. */
|
||||
export type IndexInjectionPlacement = 'head' | 'body'
|
||||
|
||||
/** One structured index injection row. */
|
||||
export type IndexInjection =
|
||||
/** Assign a JSON-serializable value to a `globalThis` property, ahead of later script rows. */
|
||||
| { kind: 'global'; name: string; value: unknown }
|
||||
/** Inline classic script. `text` must not contain `</script`, which would close the element early. */
|
||||
| { kind: 'script'; placement: IndexInjectionPlacement; text: string }
|
||||
/**
|
||||
* External classic script, executed in table order: a parser-blocking tag
|
||||
* when served, an awaited fetch-and-execute in the worker form (whose
|
||||
* loader resolves worker-only URLs such as `/plugins/...`).
|
||||
*/
|
||||
| { kind: 'script-src'; placement: IndexInjectionPlacement; src: string }
|
||||
/** A `<style>` element in the head. `text` must not contain `</style`, which would close the element early. */
|
||||
| { kind: 'style'; text: string }
|
||||
/** Raw markup fragment. */
|
||||
| { kind: 'html'; placement: IndexInjectionPlacement; html: string }
|
||||
|
||||
/** Escape a row value before placing it in a quoted HTML attribute. */
|
||||
function escapeHtmlAttribute(value: string): string {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
}
|
||||
|
||||
function assertNever(row: never): never {
|
||||
throw new Error(`webserver: unknown index injection row ${JSON.stringify(row)}`)
|
||||
}
|
||||
|
||||
/** Render one row to markup with its placement. */
|
||||
function renderRow(row: IndexInjection): { placement: IndexInjectionPlacement; markup: string } {
|
||||
switch (row.kind) {
|
||||
case 'global': {
|
||||
// `<` is escaped in JSON so a row-controlled string cannot break out of
|
||||
// the script element.
|
||||
const name = JSON.stringify(row.name).replaceAll('<', '\\u003c')
|
||||
const value = row.value === undefined
|
||||
? 'undefined'
|
||||
: JSON.stringify(row.value).replaceAll('<', '\\u003c')
|
||||
return { placement: 'head', markup: `<script>globalThis[${name}] = ${value}</script>` }
|
||||
}
|
||||
case 'script':
|
||||
return { placement: row.placement, markup: `<script>${row.text}</script>` }
|
||||
case 'script-src':
|
||||
return { placement: row.placement, markup: `<script src="${escapeHtmlAttribute(row.src)}"></script>` }
|
||||
case 'style':
|
||||
return { placement: 'head', markup: `<style>${row.text}</style>` }
|
||||
case 'html':
|
||||
return { placement: row.placement, markup: row.html }
|
||||
default:
|
||||
return assertNever(row)
|
||||
}
|
||||
}
|
||||
|
||||
/** Insert `markup` into `html` at `at`. */
|
||||
function splice(html: string, at: number, markup: string): string {
|
||||
return `${html.slice(0, at)}${markup}${html.slice(at)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Render rows into an index.html body: head rows immediately after the
|
||||
* opening head tag, body rows immediately after the opening body tag, each
|
||||
* group in table order.
|
||||
* @param html - the raw index.html body.
|
||||
* @param rows - the collected injection table.
|
||||
* @returns the html with every row rendered.
|
||||
*/
|
||||
export function renderIndexInjections(html: string, rows: readonly IndexInjection[]): string {
|
||||
let head = ''
|
||||
let body = ''
|
||||
for (const row of rows) {
|
||||
const rendered = renderRow(row)
|
||||
if (rendered.placement === 'head') head += rendered.markup
|
||||
else body += rendered.markup
|
||||
}
|
||||
let out = html
|
||||
if (head !== '') {
|
||||
const open = /<head(?:\s[^>]*)?>/i.exec(out)
|
||||
// Headless fixture pages may lack <head>; prepending keeps the rows ahead
|
||||
// of every document script.
|
||||
out = open === null ? `${head}${out}` : splice(out, open.index + open[0].length, head)
|
||||
}
|
||||
if (body !== '') {
|
||||
const open = /<body(?:\s[^>]*)?>/i.exec(out)
|
||||
// Body-less fragments receive the rows at the end, where the HTML parser
|
||||
// has already synthesized a body.
|
||||
out = open === null ? `${out}${body}` : splice(out, open.index + open[0].length, body)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import Include from '@deepseek-ai/cordis-plugin-include'
|
||||
import HttpServer from '../src/index.ts'
|
||||
import HttpServer, { renderIndexInjections } from '../src/index.ts'
|
||||
|
||||
let root: string | undefined
|
||||
let context: Context | undefined
|
||||
@@ -200,6 +200,54 @@ describe('real Loader composition', () => {
|
||||
await expect(request(port, '/probe')).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('collects injection rows fresh per render and layers taps over the rendered rows', { timeout: 60_000 }, async () => {
|
||||
const loaded = await loadComposition()
|
||||
const server = loaded.webServer
|
||||
let flag = 'dark'
|
||||
loaded.on('webserver/index-inject', (table) => {
|
||||
table.push(
|
||||
{ kind: 'script', placement: 'head', text: 'window.__Q__=1' },
|
||||
{ kind: 'script-src', placement: 'head', src: '/plugins/a.js?rev="1"&x=<y>' },
|
||||
{ kind: 'global', name: '__DSH_BOOT__', value: { rev: '</script><b>' } },
|
||||
{ kind: 'style', text: 'body{margin:0}' },
|
||||
{ kind: 'html', placement: 'head', html: '<meta name="probe">' },
|
||||
{ kind: 'script', placement: 'body', text: `window.__P__=${JSON.stringify(flag)}` },
|
||||
)
|
||||
})
|
||||
|
||||
const html = server.renderIndex('<html><head></head><body>shell</body></html>')
|
||||
// Head rows land right after the opening head tag in table order; the body
|
||||
// row lands right after the opening body tag.
|
||||
const order = [
|
||||
'<head>',
|
||||
'<script>window.__Q__=1</script>',
|
||||
'<script src="/plugins/a.js?rev="1"&x=<y>"></script>',
|
||||
'globalThis["__DSH_BOOT__"] = {"rev":"\\u003c/script>\\u003cb>"}',
|
||||
'<style>body{margin:0}</style>',
|
||||
'<meta name="probe">',
|
||||
'<body>',
|
||||
'<script>window.__P__="dark"</script>',
|
||||
'shell',
|
||||
].map(part => html.indexOf(part))
|
||||
expect(order).toEqual([...order].sort((a, b) => a - b))
|
||||
expect(order.every(at => at !== -1)).toBe(true)
|
||||
|
||||
// Fresh collection per render: the listener reads live state at emit time.
|
||||
flag = 'light'
|
||||
expect(server.renderIndex('<head></head><body></body>')).toContain('window.__P__="light"')
|
||||
|
||||
// Raw taps still run, over the already-rendered rows.
|
||||
const untap = server.tapIndex(h => h.replace('window.__Q__=1', 'window.__Q__=2'))
|
||||
expect(server.renderIndex('<head></head><body></body>')).toContain('window.__Q__=2')
|
||||
untap()
|
||||
|
||||
// Tag-less fragments: head rows prepend, body rows append.
|
||||
expect(renderIndexInjections('<main>x</main>', [
|
||||
{ kind: 'script', placement: 'head', text: 'H' },
|
||||
{ kind: 'script', placement: 'body', text: 'B' },
|
||||
])).toBe('<script>H</script><main>x</main><script>B</script>')
|
||||
})
|
||||
|
||||
it('fails the fiber when the port is already taken (fail-loud at activation)', { timeout: 60_000 }, async () => {
|
||||
const first = await loadComposition()
|
||||
const takenPort = first.webServer.port
|
||||
|
||||
@@ -184,6 +184,7 @@ export const EVENT_SCOPE_PAGE: Record<string, string> = {
|
||||
'system-prompt': 'system-prompt.md',
|
||||
'session-telemetry': 'session-telemetry.md',
|
||||
'tools': 'tools.md',
|
||||
'webserver': 'web-server.md',
|
||||
'workflow': 'workflow.md',
|
||||
}
|
||||
|
||||
@@ -480,6 +481,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
PresetSpec: 'permission-presets.md',
|
||||
InvariantInstaller: 'invariants.md',
|
||||
WebRoute: 'web-server.md',
|
||||
IndexInjection: 'web-server.md',
|
||||
StorageBackend: 'storage.md',
|
||||
StorageForms: 'storage.md',
|
||||
Domain: 'storage.md',
|
||||
|
||||
Reference in New Issue
Block a user