Client Modules

The web plugin table: the Node half of the client module system in dsh-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: the carrier described in 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, not here.

Source: 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.

/**
 * One composed client entry pushed by the host (a graph row). Wire
 * single source: the host node half (package root) produces this same shape.
 * `immediately` marks stage-one prefetch; `inject` is informational graph
 * metadata (the authoritative edges live in each package's `dsh.client`
 * declaration and reach fibers through entry creation).
 */
interface WebBootEntry {
  /** Entry name == package name. */
  id: string
  /** Bundle endpoint, '/plugins/<id>/client.js?rev=<rev>'. */
  url: string
  /** Bundle content hash (cache-busting consistency anchor). */
  rev: string
  /** Package-name dependency edges, informational (preflight display / HMR diffing). */
  inject?: string[]
  /** Stage-one prefetch mark: load the script for factory registration during module-face boot. */
  immediately?: boolean
}
/** The composed client entry graph the host injects as `window.__DSH_BOOT__`. */
interface WebBootGraph {
  /** Consistency anchor over the whole graph (content + bundle hashes). */
  rev: string
  /** Composed entries; order carries no semantics (activation order is fiber inject waiting). */
  entries: WebBootEntry[]
}

Each row's rev is the bundle's content hash and rides the URL as a cache-busting query; the graph rev hashes the composed rows, so any row change changes it. immediately marks the stage-one prefetch tier (fetch and execute during module-face boot, registration only); a lazy row is fetched on first import.

The scan

A package joins the table by declaring dsh.client (platform: 'web', optional inject edges, optional immediately) in its package.json and exporting its built bundle at exports["./client"]. Package resolution anchors at the config tree's ctx.baseUrl — the cordis.yml directory, whose package declares every composed plugin as a dependency — and construction throws when that anchor is unset.

Scanning is incremental per package; there is no full-rescan code path. Every cordis internal/plugin emission (fiber construction or disposal) marks the fiber's entry name dirty, and a microtask flush reconciles each dirty name against the live loader entries. The activation pass seeds the same dirty set with all current entries and flushes synchronously, so first scan and steady state share one implementation — with opposite failure postures. At activation, a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud AggregateError listing every broken package: the fiber FAILS and the boot's fail-loud sweep reports it. In steady state, a broken package logs a warning and must not poison the others.

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

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.

The service

ClientModuleRegistry (ctx.clientModules, defined in packages/client/modules/src/index.ts) exposes reads and the rebuild face; signatures are in the generated service catalog. graph() returns the current composed graph (a stable object between changes) and clientPath(id) the bundle's absolute path. rebuilt(id) is the only entry point through which bundle content reaches the graph: it re-hashes the file, and only a real rev change recomposes the graph and notifies. onRebuilt fires per changed bundle with the new rev; onGraphChanged fires after any flush that recomposed the graph (row added or removed, or a rebuilt rev change) and is pull-model — listeners re-read graph(). Both notification paths contain listener exceptions so one throwing subscriber cannot skip later subscribers or kill whatever triggered the flush.

In development, dsh-client-hmr is the registry's watch driver: its node half stat-polls every graph row's bundle from a synchronously captured baseline, calls rebuilt(id) on change, resyncs its watch set through onGraphChanged, and broadcasts rev changes to the browser half over SSE. Production graphs omit the HMR row entirely; the module host itself never watches files.

Cordis API

Generated from source by scripts/gen-cordis-catalog.ts (verified fresh by pnpm run verify-cordis-catalog in doc-sync; regenerate with pnpm run gen-cordis-catalog) — this section is byte-identical in both language sides of the page. Signature blocks use a ts cordis-catalog fence and keep the original source JSDoc; dispatch modes are defined in the primer, and the framework-inherited ctx API lives in cordis-api/inherited.md.

ctx.clientModulesClientModuleRegistry

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).

/**
 * Current composed entry graph (stable object between changes).
 * @returns the graph served as `window.__DSH_BOOT__`.
 */
graph(): WebBootGraph

/**
 * Absolute path of an entry's client bundle.
 * @param id - entry id (package name).
 * @returns the path, or undefined for an unknown id.
 */
clientPath(id: string): string | undefined

/**
 * Re-hash one bundle (the HMR watch's registration hook — the only entry
 * point through which bundle content changes reach the graph).
 * @param id - entry id (package name).
 * @returns the new rev, or undefined for an unknown id.
 */
rebuilt(id: string): string | undefined

/**
 * Subscribe to bundle rebuilds; fires only when the re-hash changed the rev.
 * @param listener - receives the entry id and its new bundle rev.
 * @returns the unsubscriber.
 */
onRebuilt(listener: (id: string, rev: string) => void): () => void

/**
 * Fires after any flush that recomposed the graph (row added/removed, or a
 * rebuilt rev change). Pull model: listeners re-read {@link graph}.
 * @param listener - notified with no payload.
 * @returns the unsubscriber.
 */
onGraphChanged(listener: () => void): () => void

Source: packages/client/modules/src/index.ts:184

Client 模块

Web 插件表:dsh-client-modules 中 client 模块系统的 Node 半,以 ctx.clientModulesClientModuleRegistry)形式提供。它扫描宿主 Loader 的 entry,找出声明了 dsh.client 的包,组合出 window.__DSH_BOOT__ entry 图,在 /plugins/<id>/client.js 提供各个 bundle,并经 index 转换(index tap)注入启动 manifest(元数据清单)——这是同一个服务的四个面。它是 Web GUI 栈的一项可选能力,不属于 agent loop(智能体循环)主干,并且是 dsh-host-webserver 的消费方:web-server.md 所述的载体提供本服务注册的前缀路由与 index 转换。同一个包的浏览器半(ctx.modules,即拉取并物化这些 bundle 的 lazy CJS 模块表)属于内核机件,记录在包 README中,不在本页。

源码:packages/client/modules/src/client/manifest.ts

wire

图是 Node 半与浏览器半之间协议层的唯一真源:宿主从扫描到的包组合出 WebBootEntry 行,把图作为 <head> 中的第一个脚本注入(window.__DSH_BOOT__,其中 < 已转义,插件可控的字符串因此无法逃出 script 元素),壳则在启动任何东西之前先解析它。没有有效 manifest 的页面无法启动——浏览器侧的解析器在图缺失或畸形时大声抛错。

/**
 * One composed client entry pushed by the host (a graph row). Wire
 * single source: the host node half (package root) produces this same shape.
 * `immediately` marks stage-one prefetch; `inject` is informational graph
 * metadata (the authoritative edges live in each package's `dsh.client`
 * declaration and reach fibers through entry creation).
 */
interface WebBootEntry {
  /** Entry name == package name. */
  id: string
  /** Bundle endpoint, '/plugins/<id>/client.js?rev=<rev>'. */
  url: string
  /** Bundle content hash (cache-busting consistency anchor). */
  rev: string
  /** Package-name dependency edges, informational (preflight display / HMR diffing). */
  inject?: string[]
  /** Stage-one prefetch mark: load the script for factory registration during module-face boot. */
  immediately?: boolean
}
/** The composed client entry graph the host injects as `window.__DSH_BOOT__`. */
interface WebBootGraph {
  /** Consistency anchor over the whole graph (content + bundle hashes). */
  rev: string
  /** Composed entries; order carries no semantics (activation order is fiber inject waiting). */
  entries: WebBootEntry[]
}

每一行的 rev 是该 bundle 的内容哈希,并作为使缓存失效的查询参数附在 URL 上;图的 rev 对组合后的各行做哈希,因此任何一行的变化都会改变它。immediately 标记第一阶段预取档位(在模块面启动期间 fetch 并执行,只做登记);惰性行在首次 import 时才拉取。

扫描

包加入这张表的方式,是在自己的 package.json 中声明 dsh.clientplatform: 'web'、可选的 inject 边、可选的 immediately),并在 exports["./client"] 导出构建好的 bundle。包解析锚定在配置树的 ctx.baseUrl——即 cordis.yml 所在目录,该目录的包把每个被组合的插件声明为依赖——这一锚点未设置时,构造即抛错。

扫描是单包增量的;不存在全量重扫代码路径。fiber 构造或 dispose(资源释放)时的每次 cordis internal/plugin 发射都把该 fiber 的 entry 名标脏,一次微任务 flush 把每个脏名与实时 loader entry 对账。激活趟以全部当前 entry 灌入同一个脏集合并同步 flush,因此初扫与稳态共享一条实现——但失败姿态相反。激活时,已加载 entry 中的畸形声明或缺失 bundle 会聚合为一个大声的 AggregateError,列出每个损坏的包:该 fiber 进入 FAILED,由启动的大声失败 sweep 上报。稳态下,损坏的包只记录一条警告,且不得殃及其他包。

包元数据——包括「非 client 包」这一否定结论——按名缓存且永不过期:插件集合的变更在重启后生效。fiber 重启原样复用其行与 rev;bundle 内容变更只经 rebuilt() 到达图。

bundle 路由与 index 转换

GET/HEAD /plugins/<id>/client.jsno-cache 从磁盘提供已注册的 bundle(锚定一致性的是 rev 查询参数,而非 HTTP 缓存);其他方法返回 405。未知 id——或已注册、但 bundle 因尚未构建而不可读的行——回应一个大声的 404,而不是让载体的 SPA 回退把 HTML 当作 JavaScript 发出。index 转换在每次 index 渲染时注入当前图,因此刷新页面总是针对实时组合启动。

服务

ClientModuleRegistryctx.clientModules,定义于 packages/client/modules/src/index.ts)暴露读取面与重建面;签名见生成的服务目录graph() 返回当前组合出的图(两次变更之间是同一个稳定对象),clientPath(id) 返回该 bundle 的绝对路径。rebuilt(id) 是 bundle 内容到达图的唯一入口:它对文件重新哈希,只有 rev 真正变化才会重新组合图并发出通知。onRebuilt 按发生变化的 bundle 逐个触发并携带新 rev;onGraphChanged 在任何一次重新组合了图的 flush 之后触发(行的增删,或 rebuilt 带来的 rev 变化),并采用拉取模型——监听器自行重读 graph()。两条通知路径都会兜住监听器异常,因此一个抛错的订阅者既不能让后续订阅者被跳过,也不能杀死触发这次 flush 的一方。

开发环境下,dsh-client-hmr 是注册表的监视驱动:它的 Node 半从同步取得的基线出发,对图中每一行的 bundle 做 stat 轮询,变化时调用 rebuilt(id),经 onGraphChanged 重新同步监视集合,并通过 SSE(Server-Sent Events)把 rev 变化广播给浏览器半。生产环境的图完全不含 HMR(热模块替换)行;模块宿主自身从不监视文件。

Cordis API

Generated from source by scripts/gen-cordis-catalog.ts (verified fresh by pnpm run verify-cordis-catalog in doc-sync; regenerate with pnpm run gen-cordis-catalog) — this section is byte-identical in both language sides of the page. Signature blocks use a ts cordis-catalog fence and keep the original source JSDoc; dispatch modes are defined in the primer, and the framework-inherited ctx API lives in cordis-api/inherited.md.

ctx.clientModulesClientModuleRegistry

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).

/**
 * Current composed entry graph (stable object between changes).
 * @returns the graph served as `window.__DSH_BOOT__`.
 */
graph(): WebBootGraph

/**
 * Absolute path of an entry's client bundle.
 * @param id - entry id (package name).
 * @returns the path, or undefined for an unknown id.
 */
clientPath(id: string): string | undefined

/**
 * Re-hash one bundle (the HMR watch's registration hook — the only entry
 * point through which bundle content changes reach the graph).
 * @param id - entry id (package name).
 * @returns the new rev, or undefined for an unknown id.
 */
rebuilt(id: string): string | undefined

/**
 * Subscribe to bundle rebuilds; fires only when the re-hash changed the rev.
 * @param listener - receives the entry id and its new bundle rev.
 * @returns the unsubscriber.
 */
onRebuilt(listener: (id: string, rev: string) => void): () => void

/**
 * Fires after any flush that recomposed the graph (row added/removed, or a
 * rebuilt rev change). Pull model: listeners re-read {@link graph}.
 * @param listener - notified with no payload.
 * @returns the unsubscriber.
 */
onGraphChanged(listener: () => void): () => void

Source: packages/client/modules/src/index.ts:184