permission, plan, invariants, http-server, storage (hub + backend seam + domain form + domain/changed), workspace, tui, and client-modules complete the docs/subsystems tier: every ctx service and event scope now has one owning page, the precondition for generating per-subsystem service/event reference into these pages. 25 new type-equiv manifest entries; 16 types move from TYPE_LINK_EXEMPTIONS to LINK_MAP now that they have catalog homes (dead InvariantRegistration exemption removed; catalogs regenerated); core.md's sub-page table gains the eight rows in both languages; the owning subsystems-catalog Agent Note records the coverage extension. Chinese counterparts and pair records follow in the next commit.
6.3 KiB
Client Modules
English | 中文
The web plugin table: the Node half of the client module system in dsh-client-modules, provided as ctx.clientModuleHost (ClientModuleHostService). It scans the host Loader's entries for dshClient packages, 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 http-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 (web2 §0 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 dshClient
* 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: fetch + execute (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 dshClient (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
ClientModuleHostService (ctx.clientModuleHost, 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.