dsh web config-tree boot and the web transport layering
dsh web 的 config-tree boot 与 web 传输分层
`dsh web` was the only hand-assembled surface left: `bootHost` mounted 32 plugins with configs pinned in code (violating no-hardcoded-tunables), the client roster was a `web.ts` constant, and TUI/headless had long been yml compositions. The transport layer misplaced responsibilities to match: the webserver self-described as a dumb carrier yet knew the `__DSH_BOOT__` graph, owned the SSE channel, and hard-coded the `/
English
Problem
dsh web was the only hand-assembled surface left: bootHost mounted 32 plugins with configs pinned in code (violating no-hardcoded-tunables), the client roster was a web.ts constant, and TUI/headless had long been yml compositions. The transport layer misplaced responsibilities to match: the webserver self-described as a dumb carrier yet knew the __DSH_BOOT__ graph, owned the SSE channel, and hard-coded the /api/* prefix; the dev bundle watch lived inside the prod registry behind a watch? flag with no lifecycle owner; the graph registry rescanned everything on every internal/plugin emission; per-request errors and fatal server errors shared one sink that always exited the process. One user-visible defect rode along: the web path never loaded $DSH_HOME/.env, so DSH_HOME=… dsh web could not find an API key living there.
Decision
Composition is one flat assembled tree. apps/cli/config/base.cordis.yml plus apps/cli/config/web.cordis.yml holds every row — the host runtime (32 rows), the api-gateway row, the webserver row, and the dsh.client rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into apps/cli/config/base.cordis.yml and each surface reduced to an overlay (shared-base overlays). The dsh-client-hmr row is an ordinary always-on bundle row (originally appended in code by --dev; the flag is retired). Row order carries no load semantics; activation is service-availability driven. The shared audit rejects imports with no fiber, awaits only failed fibers to recover original activation errors, and reports services that leave a fiber PENDING; before throwing, it marks those exact rejection reasons through one process checkpoint so installFailLoud coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. The Node app-boot artifact embeds @cordisjs/plugin-include while leaving @cordisjs/plugin-loader external, so the include's EntryTree and the host bind to one Loader peer instead of splitting a config tree across two Loader implementations.
Boot glue is a class pair. AppCLIEntry (apps/cli) and AppWebEntry (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. AppCLIEntry.run() is three stages: layered env (ambient > cwd .env > $DSH_HOME/.env, closing the defect above) → patch composition → Loader include boot plus the activation audit. AppWebEntry.run() mirrors it browser-side: parse window.__DSH_BOOT__ into a BootManifest (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the immediately tier in parallel with Context/Loader setup, await the prefetch before creating entries (materialization is tree.import's synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep.
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 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, 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.
Consequences
- Recomposing a web deployment is a yml/patch edit; the retired pieces (
mountWebPlugins,CLIENT_PACKAGES,createHostWebPluginRegistry,startWebServer, the webserver's graph/SSE/api knowledge) are deleted. - Headless is a direct core entry point: its shipped profile contains the shared base Agent capabilities and omits Host, HTTP, Web, and browser layers. The transport split in this note is the browser surface's contract.
- A TypeScript pitfall worth remembering: a
declare module 'cordis'augmentation in a file with no cordis import is demoted to a standalone module declaration and silently shatters the program-wideContextmerge (ctx.on/ctx.effectvanish across the program). Anchor withimport type {} from 'cordis'.
Alternatives considered
| Rejected | One-line reason |
|---|---|
Dedicated dsh-host-profile receiver package | User model state belongs to the Settings-backed ctx.agentDefaultModel; an extra Host receiver would duplicate ownership and exclude direct entry points |
Runtime assembly shim plugin providing an apiHandler service | Existed only because createApiProxy lived in runtime; moving it into apiproxy made the gateway self-hosting, and toFetchHandler is a pure function the binding side calls |
| Full-rescan + incremental scan coexisting | Two implementations, two semantics; the single per-package path covers the activation pass too |
A bespoke ./impl export on the modules package | Non-uniform exports; the standard ./client carries the whole browser half |
dev overlay / cordis.dev.yml | One yml; !!js cannot conditionalize row existence, and --dev appending one row is the entire difference |
| env vars in the mapping table | The same field would gain env/json double sourcing and need an invented precedence |
Unbarriered create-after-prefetch (arrive() dedup as safety) | Disproved by a 10–25% boot race: in-flight dedup covers same-package double-fetch, not cross-package synchronous require edges |
| json file used directly as loader patches | json keys would couple to yml row structure; profile writers would need cordis knowledge |
中文
问题
dsh web 曾是仅剩的手工装配面:bootHost 逐个挂 32 个插件、config 钉死在代码里(违反 no-hardcoded-tunables),client roster 是 web.ts 常量,而 TUI/headless 早已是 yml 组合。传输层的职责错位与之配套:webserver 自称哑载体却认识 __DSH_BOOT__ 图、拥有 SSE(Server-Sent Events)通道、硬编码 /api/* 前缀;dev 的 bundle watch 寄居在 prod 注册表里靠 watch? 参数开关、生命周期无主;图注册表对每次 internal/plugin 全量重扫;单请求失败与致命 server 错误共用一个一律退出进程的 sink。还有一个用户可见缺陷:web 路径从不加载 $DSH_HOME/.env,DSH_HOME=… dsh web 读不到自定义 home 下的 API key。
决策
组合结果是一棵平铺配置树。 apps/cli/config/base.cordis.yml 与 apps/cli/config/web.cordis.yml 共同持有全部行——host 运行时(32 行)、api-gateway 行、webserver 行、dsh.client 行(浏览器 roster;modules 行同时是 host 行)。不做主干 bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 apps/cli/config/base.cordis.yml,各 surface 则收敛为一份 overlay(共享 base overlay)。dsh-client-hmr 行是普通的始终启用的 bundle 行(最初由 --dev 在代码中追加;该旗标已废除)。行序无装载语义;激活由服务可用性驱动。共享 audit 会拒绝没有 fiber 的 import、仅等待失败的 fiber 以恢复原始激活错误,并报告让 fiber 停在 PENDING 的服务;抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 installFailLoud 将 Loader 的重复通知合并为一次,而无关的未处理 rejection 仍然致命。Node app-boot 产物内嵌 @cordisjs/plugin-include,但将 @cordisjs/plugin-loader 保持为外部依赖,因此 include 的 EntryTree 与 host 会绑定到同一个 Loader peer,而不会让一棵配置树横跨两个 Loader 实现。
boot 胶水由两个类组成。 AppCLIEntry(apps/cli)与 AppWebEntry(壳内核)只持有那些必须独立于 cordis、提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest(元数据清单)、模块系统实例、loading 页句柄——其余一律进插件。AppCLIEntry.run() 三段:分层 env(ambient > cwd .env > $DSH_HOME/.env,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加 activation audit。AppWebEntry.run() 在浏览器侧镜像它:把 window.__DSH_BOOT__ 解析成 BootManifest(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、create entry 之前等预取齐(物化是 tree.import 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐一创建图行、settle、sweep。
每个配置源有唯一声明位置。 组合包 yml 值是工程默认,Settings 分节是可写的用户偏好,CLI(命令行界面)flags 面向其归属的启动器配置行,env 值则通过 yml !!js 表达式进入。patch 会整体替换一行的 config。解析后的前端 distIndex 通过同一条 patch 通道作为组装事实传递。与传输无关的提供方/模型默认值归 ctx.agentDefaultModel 所有;直接 headless 入口与 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 即抛、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。
后果
- 重组一个 web 部署 = 改 yml/patch;退役件(
mountWebPlugins、CLIENT_PACKAGES、createHostWebPluginRegistry、startWebServer、webserver 的图/SSE/api 知识)全部删除。 - Headless 是直接 core 入口:其随附 profile 包含共享的 base Agent 能力,并省去 Host、HTTP、Web 与浏览器层。本笔记的传输划分是浏览器 surface 的约定。
- 一个值得记住的 TypeScript 坑:
declare module 'cordis'augmentation 所在文件若没有任何 cordis import,会被降级成独立模块声明,无声打散全程序的Contextmerge(ctx.on/ctx.effect全程序消失)。用import type {} from 'cordis'锚定。
考虑过的替代方案
| 弃案 | 一行理由 |
|---|---|
专门的 dsh-host-profile 受体包 | 用户模型状态归 Settings 支撑的 ctx.agentDefaultModel 所有;额外的 Host 受体会重复归属,并排除直接入口 |
运行时里的 assembly 垫层插件(provide apiHandler) | 它的存在只因 createApiProxy 住运行时;本体迁入 apiproxy 后网关可自承载,且 toFetchHandler 是绑定方自己调的纯函数 |
| 全量重扫与增量扫描并存 | 两条实现两份语义;单包路径足以覆盖激活初扫 |
modules 包特设 ./impl 出口 | 出口不统一;标准 ./client 承载完整浏览器半 |
dev overlay / cordis.dev.yml | 一套 yml;!!js 无法条件化行存在性,--dev 追加一行就是全部差异 |
| env 进映射表 | 同一字段将出现 env/json 双源,需再发明优先级 |
create 不等预取(以 arrive() 去重为安全依据) | 被 10–25% boot 竞态证伪:在途去重只覆盖同包双拉,不覆盖跨包同步 require 边 |
| json 直接当 loader patches 文件 | json 键名将耦合 yml 行结构,profile 编写者要懂 cordis |