8.9 KiB
Agent Note: dsh web config-tree boot and the web transport layering
Status: implemented
English | 中文
Scope: how
dsh webcomposes (cordis.yml + pre-cordis boot classes + config sources) and how the web transport splits across packages (gateway / carrier / binding / graph / dev-reload). The client plugin loading note owns the browser-side loading chain this composition feeds.
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 dshClient 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). --dev appends the dsh-client-hmr row in code before the settle audit — prod and dev differ by exactly that row. 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. yml static values are engineering defaults; the profile json (./.dsh-tmp-profile/config.json, read-only, never created, cwd-anchored until the $DSH_HOME migration) is user config mapped through a static PROFILE_MAPPINGS table onto target rows (provider/model → the api-gateway row, persistenceRoot → the jsonl row); CLI flags map onto the webserver row with a field set disjoint from the json's; env values enter through yml !!js expressions, never through the mapping table. Patches replace a row's config wholesale, so the entry class re-reads the yml row's static values (bypass parse) and merges overrides on top. An unmapped json key fails loud. The resolved frontend distIndex rides the same patch channel — an assembly fact, not user config.
The transport splits five ways. dsh-host-apiproxy upgraded to the gateway plugin (api-gateway row): default-exports ApiProxyService, config {provider, model}, provides ctx.apiProxy, transport-agnostic and registers no routes — createApiProxy moved here from the retired runtime package. dsh-host-webserver shrank to a plain route-registration plugin: HttpServerService provides ctx.httpServer (register(route) → disposer with duplicate-pattern throw, tapIndex transforms applied in registration order, port), listens on activation, per-request failures answer 400 and log without exiting, and knows no harness concepts. The connection node half owns the binding: it injects both services and registers toFetchHandler(ctx.apiProxy) under the /api prefix — future IPC carriers swap connection's transport while the gateway stays untouched. The modules node half (ClientModuleHostService, providing ctx.clientModuleHost) owns the graph: incremental per-package scanning (no full-rescan code path — internal/plugin marks the fiber's entry name dirty, a flush reconciles each name against live entries, package metadata including negative verdicts is cached forever, re-hashing is reachable only through rebuilt(id)), the bundle route, the index tap, and onRebuilt/onGraphChanged notification. The hmr node half owns dev reload: fs.watchFile stat-polling driven by onGraphChanged 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 boots the same composition through the same entry (landed in the stacked follow-up): port 0 is its only surface difference, the model face gains
ask_user_question/workspace context/model titles per the unification ruling, andbootHost/startHostretired with thedsh-host-runtimepackage. The profile write path, the$DSH_HOMEprofile relocation, and IPC carriers remain recorded deferrals. - 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 |
The profile json is consumed at patch time; the only runtime consumer of {provider, model} is the gateway itself — its config is the receiver |
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 export surface; 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 |