diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml index 54df5f07ab..e64c0b1912 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml @@ -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 -2026-07-23-client-plugin-loading-model.md: 58651fd258a6b2929c58bb6f93b44adb6e8e1818 -2026-07-23-client-plugin-loading-model.zh.md: f60b06c7bfaa9c70170082ac4384ba2bd899676e +2026-07-23-client-plugin-loading-model.md: 9f8b69739213b9bdc52e4b4de4d663419e596c66 +2026-07-23-client-plugin-loading-model.zh.md: 05a78fbba9859378178720f012af462382b3ab0f diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md index 58651fd258..9f8b697392 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md @@ -56,11 +56,11 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the **Host side — compose the graph.** -1. The composing app (`apps/cli`) mounts the roster as in-memory Loader entries via `mountWebPlugins`. The roster is one flat list of the plugin packages, plus the `client-hmr` row under `--dev`. A roster package that fails to import throws loud at mount. -2. The registry (`createHostWebPluginRegistry`) scans the mounted entries' package.json `dshClient` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`. The `inject` edges and the `immediately` mark come from manifests, never hand-copied. It refuses a declared plugin without a built `./client` bundle, and any malformed declaration field — load-time fail loud. -3. The registry rescans on cordis `internal/plugin`, microtask-debounced; a rescan failure keeps serving the previous graph. Each bundle's content is hashed into its `rev` (cache busting + HMR diff anchor), and the row set into `graph.rev`. Every row is fetch-served: `/plugins//client.js?rev=…`. The graph types are a wire contract dual-held on both sides, because the webserver keeps zero workspace dependencies. +1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, and `--dev` appends the `client-hmr` row in code (`AppCLIEntry`) before the settle/sweep so the fail-loud triple covers it. A roster row that fails to import is caught by the boot's `assertEntriesLoaded`. +2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dshClient` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`. The `inject` edges and the `immediately` mark come from manifests, never hand-copied. It refuses a declared plugin without a built `./client` bundle, and any malformed declaration field — activation-time fail loud (a FAILED fiber the sweep reports). +3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per name forever and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Each bundle's content hash is its `rev` (cache busting + HMR diff anchor), the row set hashes into `graph.rev`, and every row is fetch-served: `/plugins//client.js?rev=…`. The graph types are single-sourced in the modules package's `./impl` export — the webserver knows nothing about the graph (it is a plain route-registration plugin; modules registers the bundle route and taps the index render itself). -Why is the roster a hand-written list and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a dshClient package existing in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call. The roster lives in `apps/cli/web.ts` rather than cordis.yml only because `dsh web`'s host is a hand-assembled `bootHost` with no Loader config tree yet. +Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a dshClient package existing in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted. **Phase one — the module face.** The shell builds the module system over the graph, then prefetches every `immediately` row in parallel. Prefetch is fetch + execute, which registers factories only. A single row's prefetch failure is swallowed here: phase two's import retries the fetch and owns the loud failure, so one bad row cannot mask the others. `immediately` is a prefetch mark — not a barrier, not an identity. The package declares it, the registry carries it into the row. The infrastructure plugins (connection, runtime, ui-theme, i18n, plus hmr) declare it; UI plugins simply arrive on demand. @@ -74,9 +74,9 @@ Why is the roster a hand-written list and not a scan? Because which plugins comp ### Hot reload: one driver plugin, self-watched bundles -Whether hot reload is active is a composition decision: dev graphs include the `client-hmr` row (a normal plugin package) and turn on bundle watching; prod graphs do neither. +Whether hot reload is active is a composition decision: dev compositions mount the `client-hmr` row (a normal plugin package, appended by `--dev`) whose node half brings the bundle watch and the SSE channel; prod compositions mount nothing and have neither. -How does a rebuilt bundle become a reload signal? The webserver observes it itself — no builder tells it. The registry scan already holds every plugin's bundle path (`clientPath`), so in dev mode the registry stat-polls each scanned bundle file with `fs.watchFile`. Polling is by design: inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`. On a mtime/size change the registry re-hashes that row (`rebuilt(id)`); when the `rev` actually changed, it broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Watch set membership follows the table: rescans add watches for new rows and drop them for vanished ones, dispose drops all. The poll interval is a validated config field (default 500ms), not a constant. Rebuilding the bundles is any tsdown watch process's business — `scripts/dev-web.ts` remains as the watch-build entry point, its package list dshClient-discovered by scanning `packages/*/*/package.json` at startup — and builder and host share zero protocol. A torn read of a half-written bundle self-heals: the stats keep changing while the write completes, so the next poll tick re-hashes again and broadcasts the final rev. +How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. It reads the graph's bundle paths from `ctx.clientModuleHost.clientPath(id)` and stat-polls each with `fs.watchFile`, following graph membership through `onGraphChanged` (rows added late in the boot window get watches; vanished rows drop them; all lifecycles ride `ctx.effect`). Polling is by design: inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`. On a mtime/size change it calls `clientModuleHost.rebuilt(id)` — the single re-hash entry point — and when the `rev` actually changed, broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. The poll interval is a validated config field (default 500ms), not a constant. Rebuilding the bundles is any tsdown watch process's business — `scripts/dev-web.ts` remains as the watch-build entry point, its package list dshClient-discovered by scanning `packages/*/*/package.json` at startup — and builder and host share zero protocol. A torn read of a half-written bundle self-heals: the stats keep changing while the write completes, so the next poll tick re-hashes again and broadcasts the final rev. On the browser side, the driver reloads one plugin per frame, serialized: @@ -116,7 +116,7 @@ One governance implementation runs on both sides of the wire; the browser-specif Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows are informational — activation truth is service-level — so a mismatch surfaces at the settled sweep, not at graph validation; and the three not-yet-promoted libraries keep their static-import export surface until their DI conversions land. -Roster endgame: when `dsh web` moves to config-tree boot, the roster lands in cordis.yml — client plugin packages become ordinary config-tree entry rows, `mountWebPlugins` and the `CLIENT_PACKAGES` constant disappear, and recomposing a deployment means swapping the yml/overlay. The registry needs zero changes for that move, since its `internal/plugin` subscription already discovers whatever entries the tree mounts. +Roster endgame (landed 2026-07-25 with the config-tree boot move): the roster lives in `apps/cli/cordis.yml`, `mountWebPlugins` and the `CLIENT_PACKAGES` constant are gone, and recomposing a deployment means swapping the yml/overlay. The graph composer moved from a webserver-side registry into the `dsh-client-modules` node half (the package upgraded to dual-face per this note's promotion rule — its consumer now reaches it through cordis DI), and the transport split landed alongside: the webserver became a plain route-registration plugin, `/api/*` binding moved to the connection node half over the upgraded `api-gateway` plugin (`dsh-host-apiproxy` providing `ctx.apiProxy`), and the dev bundle watch + SSE channel moved to the hmr node half. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md index f60b06c7bf..05a78fbba9 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md @@ -56,11 +56,11 @@ vendored Loader 经其 `internal` seam 消费模块系统——唯一调用点 **host 侧——组合这张图。** -1. 负责组合的 app(`apps/cli`)经 `mountWebPlugins` 把名册挂载为内存中的 Loader entry。名册是插件包的一张平铺清单,`--dev` 下外加 `client-hmr` 行。名册里 import 失败的包在挂载时大声抛错。 -2. 注册表(`createHostWebPluginRegistry`)扫描已挂载 entry 的 package.json `dshClient` 声明,组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`。`inject` 边与 `immediately` 标记都来自 manifest,永不人肉抄写。它拒绝声明了插件却没有已构建 `./client` bundle 的包,也拒绝任何畸形的声明字段——装载期大声失败。 -3. 注册表在 cordis `internal/plugin` 上重扫,微任务去抖;重扫失败则继续供给上一张图。每个 bundle 的内容哈希进其 `rev`(缓存失效 + HMR diff 锚点),行集合哈希进 `graph.rev`。每一行都经 fetch 供给:`/plugins//client.js?rev=…`。图的类型是两侧各持一份的 wire 契约,因为 webserver 保持零 workspace 依赖。 +1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,`--dev` 由代码(`AppCLIEntry`)在 settle/sweep 之前追加 `client-hmr` 行,使 fail-loud 三件套一并覆盖它。名册行 import 失败由 boot 的 `assertEntriesLoaded` 捕获。 +2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dshClient` 声明,组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`。`inject` 边与 `immediately` 标记都来自 manifest,永不人肉抄写。它拒绝声明了插件却没有已构建 `./client` bundle 的包,也拒绝任何畸形的声明字段——激活期大声失败(FAILED fiber,由 sweep 上报)。 +3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按名永久缓存,bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。每个 bundle 的内容哈希是其 `rev`(缓存失效 + HMR diff 锚点),行集合哈希进 `graph.rev`,每一行都经 fetch 供给:`/plugins//client.js?rev=…`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知(它是朴素路由注册插件;bundle 路由和 index 渲染 tap 都由 modules 自己注册)。 -为什么名册是手写清单而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个 dshClient 包存在于仓库里,不代表这次部署要挂载它,扫描发现无从替人做这个决定。名册住在 `apps/cli/web.ts` 而非 cordis.yml,只是因为 `dsh web` 的 host 还是一个手工装配的 `bootHost`,没有 Loader 配置树。 +为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个 dshClient 包存在于仓库里,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。 **第一层——模块面。**壳在图之上建起模块系统,然后并行预取每个 `immediately` 行。预取即 fetch + 执行,只登记工厂。单行预取失败在这里被吞下:第二层 import 时会重试 fetch 并拥有那次大声失败,因此一个坏行藏不住其他行。`immediately` 是预取标记——不是屏障,不是身份。包声明它,注册表把它带进图行。基础设施插件(connection、runtime、ui-theme、i18n,外加 hmr)声明它;UI 插件则径直按需到达。 @@ -74,9 +74,9 @@ vendored Loader 经其 `internal` seam 消费模块系统——唯一调用点 ### 热重载:一个驱动插件,自行监视的 bundle -热重载是否启用是一项组合决策:dev 图包含 `client-hmr` 行(一个常规的插件包)并开启 bundle 监视;prod 图两者皆无。 +热重载是否启用是一项组合决策:dev 组合挂载 `client-hmr` 行(一个常规的插件包,由 `--dev` 追加),其 node 半带来 bundle 监视与 SSE 通道;prod 组合不挂载,两者皆无。 -重建好的 bundle 怎么变成重载信号?webserver 自己观察——没有构建器来通知它。注册表扫描本就握有每个插件的 bundle 路径(`clientPath`),因此 dev 模式下注册表用 `fs.watchFile` 对每个已扫描的 bundle 文件做 stat 轮询。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因。mtime/size 一变,注册表就重哈希该行(`rebuilt(id)`);当 `rev` 真的变了,才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE(Server-Sent Events)通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。监视集合的成员随表走:重扫为新行添加监视、为消失的行撤下监视,dispose(资源释放)撤掉全部。轮询间隔是一个经校验的配置字段(默认 500ms),不是常量。重建 bundle 则是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dshClient 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。 +重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。它从 `ctx.clientModuleHost.clientPath(id)` 读取图上各行的 bundle 路径并用 `fs.watchFile` 逐一 stat 轮询,监视集合的成员随 `onGraphChanged` 走(boot 窗口内晚到的行补上监视、消失的行撤下监视,生命周期全部收 `ctx.effect`)。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因。mtime/size 一变,它调用 `clientModuleHost.rebuilt(id)`——重哈希的唯一入口;当 `rev` 真的变了,才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE(Server-Sent Events)通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询间隔是一个经校验的配置字段(默认 500ms),不是常量。重建 bundle 则是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dshClient 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。 浏览器侧,驱动插件每帧重载一个插件,串行执行: @@ -116,7 +116,7 @@ wire 两侧跑着同一份治理实现;浏览器特有的表面只是一套模 接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;图中 `inject` 行仅是信息性说明——激活的真相在服务层——因此不匹配会在 settled 扫描时浮出,而不是在图校验时被拦下;三个尚未升格的库在各自的 DI 转换落地之前保持静态 import 的导出面。 -名册的终局:当 `dsh web` 迁到配置树 boot,名册落进 cordis.yml——client 插件包变成普通的配置树 entry 行,`mountWebPlugins` 与 `CLIENT_PACKAGES` 常量消失,重组一次部署等于换 yml/overlay。注册表为这次迁移零改动,因为它的 `internal/plugin` 订阅本就发现配置树挂载的任何 entry。 +名册的终局(2026-07-25 随配置树 boot 迁移落地):名册住 `apps/cli/cordis.yml`,`mountWebPlugins` 与 `CLIENT_PACKAGES` 常量已消失,重组一次部署等于换 yml/overlay。图的组合器从 webserver 侧的注册表迁进 `dsh-client-modules` 的 node 半(该包按本 note 的升级法则升格为双面——其消费方现经 cordis DI 到达),传输拆分同轮落地:webserver 变为朴素路由注册插件,`/api/*` 绑定迁到 connection 的 node 半、走升格后的 `api-gateway` 插件(`dsh-host-apiproxy` 提供 `ctx.apiProxy`),dev 的 bundle 监视与 SSE 通道迁到 hmr 的 node 半。 ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml new file mode 100644 index 0000000000..795cb97082 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml @@ -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 +2026-07-24-web-config-tree-boot-and-transport-layering.md: 9e93b828d5f11060aa476396f6981320c33485a5 +2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 996a5705bd5d00a2163a146ef8210247f512e6fa diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md new file mode 100644 index 0000000000..9e93b828d5 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md @@ -0,0 +1,42 @@ +# Agent Note: dsh web config-tree boot and the web transport layering + +Status: implemented + +English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) + +> Scope: how `dsh web` composes (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](2026-07-23-client-plugin-loading-model.md) 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 config tree.** `apps/cli/cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the ten `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. `--dev` appends the `dsh-client-hmr` row in code before the settle sweep — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven, and the boot compensates with a fail-loud triple: `assertEntriesLoaded` (import failures), `installFailLoud` (late apply rejections), and an all-ACTIVE sweep (PENDING fibers — cordis inject waiting has no timeout). + +**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 triple. `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 runtime (dependency direction allows it; runtime keeps `bootHost`/`startHost` for headless). `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 still boots through `bootHost` (unchanged this round); its migration, the profile write path, the `$DSH_HOME` profile relocation, and IPC carriers are recorded deferrals in the design ledger. +- 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-wide `Context` merge (`ctx.on`/`ctx.effect` vanish across the program). Anchor with `import 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 | diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md new file mode 100644 index 0000000000..996a5705bd --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md @@ -0,0 +1,42 @@ +# Agent Note:dsh web 的 config-tree boot 与 web 传输分层 + +Status: implemented + +[English](2026-07-24-web-config-tree-boot-and-transport-layering.md) | 中文 + +> 范围:`dsh web` 如何组合(cordis.yml + cordis 之前的 boot 类 + 配置源),以及 web 传输如何跨包分层(网关 / 载体 / 绑定 / 图 / 开发期重载)。浏览器侧装载链归 [client 插件装载 note](2026-07-23-client-plugin-loading-model.md) 所有,本组合只是它的供给方。 + +## 问题 + +`dsh web` 曾是仅剩的手工装配面:`bootHost` 逐个挂 32 个插件、config 钉死在代码里(违反 no-hardcoded-tunables),client roster 是 `web.ts` 常量,而 TUI/headless 早已是 yml 组合。传输层的职责错位与之配套:webserver 自称哑载体却认识 `__DSH_BOOT__` 图、拥有 SSE 通道、硬编码 `/api/*` 前缀;dev 的 bundle watch 寄居在 prod registry 里靠 `watch?` 参数开关、生命周期无主;图 registry 对每次 `internal/plugin` 全量重扫;单请求失败与致命 server 错误共用一个一律退进程的 sink。还有一个用户可见缺陷:web 路径不装 `$DSH_HOME/.env`,`DSH_HOME=… dsh web` 读不到自定义 home 下的 API key。 + +## 决策 + +**组合是一棵平铺 config tree。** `apps/cli/cordis.yml` 持有全部行——host runtime(32 行)、`api-gateway` 行、`webserver` 行、十个 `dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。`--dev` 在 settle sweep 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动,boot 以 fail-loud 三件套补偿:`assertEntriesLoaded`(import 失败)、`installFailLoud`(迟到的 apply 拒绝)、all-ACTIVE sweep(PENDING fiber——cordis inject 等待没有超时)。 + +**boot 胶水是一对 class。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有独立于 cordis 必须提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加三件套。`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、逐图行 create、settle、sweep。 + +**每个配置源有唯一声明位置。** yml 静态值是工程默认;profile json(`./.dsh-tmp-profile/config.json`,只读、绝不创建、暂锚 cwd 直至 `$DSH_HOME` 迁移)是用户配置,经静态 `PROFILE_MAPPINGS` 表映射到目标行(`provider`/`model` → `api-gateway` 行,`persistenceRoot` → jsonl 行);CLI flags 映射到 `webserver` 行、字段集与 json 不相交;env 值经 yml `!!js` 表达式进入,绝不进映射表。patch 整体替换行 config,故 entry 类旁路 parse 重读 yml 行静态值再叠加覆盖。未映射的 json 键 fail loud。解析出的前端 `distIndex` 走同一 patch 通道——装配事实,不是用户配置。 + +**传输五分。** `dsh-host-apiproxy` 升格网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,config `{provider, model}`,provide `ctx.apiProxy`,传输无关、不注册路由——`createApiProxy` 从 runtime 迁入(依赖方向允许;runtime 保留 `bootHost`/`startHost` 供 headless)。`dsh-host-webserver` 缩成朴素路由注册插件:`HttpServerService` provide `ctx.httpServer`(`register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen,单请求失败答 400 并记日志不退进程,不认识任何 harness 概念。connection node 半拥有绑定:inject 两个服务,把 `toFetchHandler(ctx.apiProxy)` 注册在 `/api` 前缀下——将来 IPC 载体只换 connection 的传输,网关零改动。modules node 半(`ClientModuleHostService`,provide `ctx.clientModuleHost`)拥有图:单包增量扫描(无全量重扫路径——`internal/plugin` 把 fiber 的 entry 名标脏,flush 逐名对账 live entries,包元数据含否定结论永久缓存,重哈希唯一入口 `rebuilt(id)`)、bundle 路由、index tap、`onRebuilt`/`onGraphChanged` 通知。hmr node 半拥有开发期重载:`fs.watchFile` stat 轮询、watch 集合跟随 `onGraphChanged`、`/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 本轮仍走 `bootHost`;它的迁移、profile 写入路径、profile 迁 `$DSH_HOME`、IPC 载体,均为设计台账中的挂账项。 +- 一个值得记住的 TypeScript 坑:`declare module 'cordis'` augmentation 所在文件若**没有任何 cordis import**,会被降级成独立 module declaration,无声打散全程序的 `Context` merge(`ctx.on`/`ctx.effect` 全程序消失)。用 `import type {} from 'cordis'` 锚定。 + +## Alternatives considered + +| 弃案 | 一行理由 | +|---|---| +| 专门的 `dsh-host-profile` 受体包 | profile json 在 patch 阶段消费完;`{provider, model}` 的唯一运行时消费方是网关自己——受体即网关 config | +| runtime 里的 `assembly` 垫层插件(provide `apiHandler`) | 它的存在只因 `createApiProxy` 住 runtime;本体迁入 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 行结构,写入方要懂 cordis | diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml new file mode 100644 index 0000000000..a85f2eaf2c --- /dev/null +++ b/apps/cli/cordis.yml @@ -0,0 +1,224 @@ +# dsh web — the full web-shape composition: host runtime (layer 1), the +# transport/service layer (layer 2), and the browser plugin roster (dshClient +# rows the modules node half scans into window.__DSH_BOOT__). Row order +# carries no load semantics (activation is service-availability driven); the +# grouping below is for readers. `--dev` appends the dsh-client-hmr row in +# code (AppCLIEntry) — prod and dev differ by exactly that one row. +# AppCLIEntry patches this tree before boot: profile json + CLI flags + +# distIndex land as config patches over the rows below (yaml = engineering +# defaults, json = user config, user wins per field). + +# ── layer 1: runtime ──────────────────────────────────────────────────────── + +- id: timer + name: '@cordisjs/plugin-timer' + +- id: llm + name: '@deepseek-ai/dsh-llm' + +- id: session + name: '@deepseek-ai/dsh-session' + +- id: session-title + name: '@deepseek-ai/dsh-session-title' + config: + fallbackMaxWords: 5 + fallbackMaxBytes: 40 + maxTitleBytes: 80 + +# Model-made titles on the first-message cadence (the web sidebar renders +# session/title). Same values as the TUI composition. +- id: session-title-llm + name: '@deepseek-ai/dsh-session-title-first-message-llm' + config: + targetWords: 5 + targetCjkCharacters: 10 + maxInputBytes: 4096 + maxOutputTokens: 64 + timeoutMs: 60000 + +- id: system-prompt + name: '@deepseek-ai/dsh-system-prompt' + config: + persona: '' + +- id: tools + name: '@deepseek-ai/dsh-tools' + +- id: user-interaction + name: '@deepseek-ai/dsh-user-interaction' + +- id: agent + name: '@deepseek-ai/dsh-agent' + +- id: tasks + name: '@deepseek-ai/dsh-tasks' + +- id: agent-loop + name: '@deepseek-ai/dsh-agent-loop' + config: + agents: [] + +# The native DeepSeek adapter; reads the key/base-url the boot's layered +# .env loading (cwd then $DSH_HOME) left in the environment. +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL + +- id: session-persistence-jsonl + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: './.sessions' + +- id: bash-local + name: '@deepseek-ai/dsh-bash-local' + +- id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' + +- id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + +- id: tool-tasks + name: '@deepseek-ai/dsh-tool-tasks' + +# fs cwd stays the package default (process.cwd()) — the same value the +# gateway injects into session.cwd, so paths and sessions agree. +- id: fs-local + name: '@deepseek-ai/dsh-fs-local' + +- id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + +- id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + +- id: workspace-context + name: '@deepseek-ai/dsh-workspace-context' + config: + maxBytes: 65536 + +- id: skill + name: '@deepseek-ai/dsh-skill' + +- id: skill-local + name: '@deepseek-ai/dsh-skill-local' + +- id: tool-skill + name: '@deepseek-ai/dsh-tool-skill' + +# token-meter rejects unknown config keys — keep this row bare. +- id: token-meter + name: '@deepseek-ai/dsh-token-meter' + +- id: compact-basic + name: '@deepseek-ai/dsh-compact-basic' + +- id: subagent + name: '@deepseek-ai/dsh-subagent' + +- id: subagent-spawn + name: '@deepseek-ai/dsh-subagent-spawn' + config: + providerName: spawn + +- id: subagent-fork + name: '@deepseek-ai/dsh-subagent-fork' + config: + providerName: fork + +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + +- id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork + +- id: workflow-workerthread + name: '@deepseek-ai/dsh-workflow-workerthread' + config: + provider: spawn + +- id: tool-workflow + name: '@deepseek-ai/dsh-tool-workflow' + +- id: timeout-policy + name: '@deepseek-ai/dsh-timeout-policy' + +- id: spill-local + name: '@deepseek-ai/dsh-spill-local' + +# Omitting maxInlineBytes makes the whole policy a silent no-op — always +# state it explicitly. +- id: spill-policy + name: '@deepseek-ai/dsh-spill-policy' + config: + maxInlineBytes: 50000 + +# The API gateway: the transport-agnostic dispatch face every client shape +# shares. provider/model are the host default routing — the profile json's +# mapping target (user config overrides these engineering defaults). +- id: api-gateway + name: '@deepseek-ai/dsh-host-apiproxy' + config: + provider: deepseek + model: deepseek-v4-flash + +# ── layer 2: transport/service ────────────────────────────────────────────── + +# Plain route-registration carrier. distIndex is an assembly fact, not user +# config — AppCLIEntry resolves the frontend dist and patches it in; host and +# port arrive as CLI-flag patches over these defaults. +- id: webserver + name: '@deepseek-ai/dsh-host-webserver' + config: + host: 127.0.0.1 + port: 3080 + +# ── browser plugin roster (dshClient rows; node halves are layer-2 hosts) ── + +# Dual-face: node half scans this very tree for dshClient rows, composes +# window.__DSH_BOOT__, serves /plugins//client.js; browser half is the +# module table the shell kernel constructs before cordis exists (§4.7 — +# adopted as a plugin entry by the kernel, never fetched). +- id: modules + name: '@deepseek-ai/dsh-client-modules' + +# Owns both ends of the web transport: node half binds the gateway to the +# webserver under /api; browser half is the fetch/SSE client. +- id: connection + name: '@deepseek-ai/dsh-client-connection' + +- id: client-runtime + name: '@deepseek-ai/dsh-client-runtime' + +- id: ui-theme + name: '@deepseek-ai/dsh-client-ui-theme' + +- id: i18n + name: '@deepseek-ai/dsh-client-i18n' + +- id: ui-layout + name: '@deepseek-ai/dsh-client-ui-layout' + +- id: ui-sidebar + name: '@deepseek-ai/dsh-client-ui-sidebar' + +- id: ui-conversation + name: '@deepseek-ai/dsh-client-ui-conversation' + +- id: ui-question + name: '@deepseek-ai/dsh-client-ui-question' + +- id: ui-trajectory + name: '@deepseek-ai/dsh-client-ui-trajectory' diff --git a/apps/cli/package.json b/apps/cli/package.json index 8557965d34..a39ce726bb 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -9,14 +9,22 @@ }, "files": [ "lib/bin.js", + "cordis.yml", "src" ], "license": "BSD-3-Clause", "dependencies": { + "@cordisjs/plugin-include": "workspace:*", + "@cordisjs/plugin-loader": "workspace:*", + "@cordisjs/plugin-timer": "workspace:*", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", + "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-hmr": "workspace:^", "@deepseek-ai/dsh-client-i18n": "workspace:^", + "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", @@ -24,13 +32,48 @@ "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", + "@deepseek-ai/dsh-compact-basic": "workspace:^", "@deepseek-ai/dsh-frontend": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-fs-policy": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-host-runtime": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-session-title": "workspace:^", + "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^", + "@deepseek-ai/dsh-skill": "workspace:^", + "@deepseek-ai/dsh-skill-local": "workspace:^", + "@deepseek-ai/dsh-spill-local": "workspace:^", + "@deepseek-ai/dsh-spill-policy": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-fork": "workspace:^", + "@deepseek-ai/dsh-subagent-spawn": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tasks": "workspace:^", + "@deepseek-ai/dsh-timeout-policy": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^", + "@deepseek-ai/dsh-tool-bash": "workspace:^", + "@deepseek-ai/dsh-tool-fs": "workspace:^", + "@deepseek-ai/dsh-tool-fs-search": "workspace:^", + "@deepseek-ai/dsh-tool-skill": "workspace:^", + "@deepseek-ai/dsh-tool-subagent": "workspace:^", + "@deepseek-ai/dsh-tool-tasks": "workspace:^", + "@deepseek-ai/dsh-tool-todo": "workspace:^", + "@deepseek-ai/dsh-tool-workflow": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-tui": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-user-interaction": "workspace:^", + "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", + "@deepseek-ai/dsh-workspace-context": "workspace:^", + "cordis": "^4.0.0-rc.7", + "js-yaml": "^4.2.0" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9" } } diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts new file mode 100644 index 0000000000..52df4203b1 --- /dev/null +++ b/apps/cli/src/app-cli-entry.ts @@ -0,0 +1,231 @@ +/** + * AppCLIEntry — the pre-cordis boot glue every dsh surface shape shares + * (config-tree boot wired for `dsh web` this round; TUI/headless migrate + * later). Everything here is what must exist before the Loader runs: layered + * env, the patch composition over the shipped cordis.yml (profile json + CLI + * flags + the resolved frontend dist), and the fail-loud triple after the + * tree settles. + */ + +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context } from 'cordis' +import type { FiberState } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include, { type PatchOptions } from '@cordisjs/plugin-include' +import yaml from 'js-yaml' +import { assertEntriesLoaded, installFailLoud, loadEnv } from '@deepseek-ai/dsh-app-boot' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' +// Empty type import carries the httpServer Context merge for the port read below. +import type {} from '@deepseek-ai/dsh-host-webserver' + +/** Profile file under the invoking directory (read-only this round; never created — see the design's profile ruling). */ +const PROFILE_DIR = '.dsh-tmp-profile' +const PROFILE_FILE = 'config.json' + +/** One profile-json key mapped onto a yml row's config field. */ +interface ProfileMapping { + jsonPath: string + entryId: string + configKey: string +} + +/** + * The static profile→row mapping table. json is user config and wins over the + * yml engineering default per field; a json key absent from this table fails + * loud (a typo silently ignored would read as "setting has no effect"). + * Developers extend deployments by adding rows here. + */ +const PROFILE_MAPPINGS: ProfileMapping[] = [ + { jsonPath: 'provider', entryId: 'api-gateway', configKey: 'provider' }, + { jsonPath: 'model', entryId: 'api-gateway', configKey: 'model' }, + { jsonPath: 'persistenceRoot', entryId: 'session-persistence-jsonl', configKey: 'root' }, +] + +// The include's YAML dialect: `!!js` scalars become expression nodes the +// Loader evaluates at entry activation. The bypass parse below must accept +// them (and passing one through a patch unchanged is legal). +const jsExprType = new yaml.Type('tag:yaml.org,2002:js', { + kind: 'scalar', + resolve: data => typeof data === 'string', + construct: data => ({ __jsExpr: String(data) }), +}) +const includeYamlSchema = yaml.JSON_SCHEMA.extend(jsExprType) + +/** + * Value mirror of cordis's `FiberState` const enum members the sweep needs + * (a const enum has no runtime object to import; same rationale as the + * client-side mirror in dsh-client-web). + */ +const FIBER_ACTIVE = 2 as FiberState.ACTIVE +const FIBER_PENDING = 0 as FiberState.PENDING + +/** Constructor facts for one `dsh web` invocation (argv already parsed by web.ts). */ +export interface AppCLIEntryOptions { + /** Absolute path of the shipped cordis.yml. */ + configPath: string + /** Whether to append the HMR row (the whole prod/dev difference). */ + dev: boolean + /** --host when explicitly passed; undefined keeps the yml engineering default. */ + host?: string + /** --port when explicitly passed; undefined keeps the yml engineering default. */ + port?: number +} + +/** + * Boot driver for the config-tree `dsh web` shape: holds only what exists + * independently of (and prior to) cordis — argv facts, the composed patch + * set, and finally the root ctx. + */ +export class AppCLIEntry { + /** The root context, set by {@link run}. */ + ctx!: Context + + private patches: PatchOptions[] = [] + + constructor(private readonly options: AppCLIEntryOptions) {} + + /** + * Run the boot chain: layered env → patch composition → Loader include + * boot (dev row before await) → fail-loud triple. + * @returns the settled root context and the listening port. + */ + async run(): Promise<{ ctx: Context; port: number }> { + this.loadEnvLayers() + this.composePatches() + await this.bootTree() + this.assertBoot() + const port = this.ctx.get('httpServer')?.port + /* v8 ignore next -- the sweep above guarantees an ACTIVE webserver row */ + if (port === undefined) throw new Error('dsh web: httpServer service missing after settled boot') + return { ctx: this.ctx, port } + } + + /** Layered .env: ambient > cwd (bin already loaded) > $DSH_HOME (loadEnvFile never overrides). */ + private loadEnvLayers(): void { + loadEnv('dsh web', resolveDshHome()) + } + + /** + * Compose the patch set from the three non-yml config sources: profile + * json (user config), CLI flags, and the resolved frontend dist. Patches + * replace a row's config wholesale, so each patched row's yml static + * values are re-read here (bypass parse) and merged under the overrides. + */ + private composePatches(): void { + const rows = this.parseYmlRows() + const overrides = new Map>() + const put = (entryId: string, key: string, value: unknown): void => { + const bag = overrides.get(entryId) ?? {} + bag[key] = value + overrides.set(entryId, bag) + } + + // Source 1: profile json (missing file = empty; unmapped key = loud). + for (const [key, value] of Object.entries(this.readProfile())) { + const mapping = PROFILE_MAPPINGS.find(m => m.jsonPath === key) + if (mapping === undefined) { + throw new Error(`dsh web: profile key "${key}" has no mapping (known: ${PROFILE_MAPPINGS.map(m => m.jsonPath).join(', ')})`) + } + put(mapping.entryId, mapping.configKey, value) + } + + // Source 2: CLI flags (field set disjoint from the json mappings). + if (this.options.host !== undefined) put('webserver', 'host', this.options.host) + if (this.options.port !== undefined) put('webserver', 'port', this.options.port) + + // Source 3: the frontend dist — an assembly fact of this app, never yml + // user config. Workspace knowledge stays here. + put('webserver', 'distIndex', this.resolveDistIndex()) + + this.patches = [...overrides.entries()].map(([id, bag]) => { + const yml = rows.get(id) + if (yml === undefined) throw new Error(`dsh web: patch target row "${id}" not found in ${this.options.configPath}`) + return { id, config: { ...(yml.config ?? {}) as Record, ...bag } } + }) + } + + /** Loader include boot; the dev HMR row mounts before await so the fail-loud triple covers it. */ + private async bootTree(): Promise { + const ctx = new Context() + ctx.baseUrl = pathToFileURL(join(resolve(this.options.configPath), '..')).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + await ctx.loader.create({ + name: 'cordis:include', + config: { + path: pathToFileURL(resolve(this.options.configPath)).href, + ...this.patches.length > 0 ? { patches: this.patches } : {}, + }, + }) + if (this.options.dev) { + await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' }) + } + this.ctx = ctx + await ctx.loader.await() + } + + /** + * Fail-loud triple: assertEntriesLoaded catches import failures, + * installFailLoud catches late apply rejections, and the all-ACTIVE sweep + * below catches PENDING fibers (cordis inject waiting has no timeout). + */ + private assertBoot(): void { + installFailLoud('dsh web') + assertEntriesLoaded(this.ctx, 'dsh web') + const failures: string[] = [] + for (const entry of this.ctx.loader.entries()) { + if (entry.fiber === undefined || entry.disabled) continue + const state = entry.fiber.state + if (state === FIBER_ACTIVE) continue + if (state === FIBER_PENDING) { + const missing = Object.keys(entry.fiber.inject).filter(service => this.ctx.get(service) === undefined) + failures.push(`${entry.options.name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`) + } else { + failures.push(`${entry.options.name}: fiber state ${String(state)}`) + } + } + if (failures.length > 0) { + throw new Error(`dsh web: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`) + } + } + + /** Bypass parse of the shipped yml (id → row) for patch-merge inputs; Loader still reads the file itself. */ + private parseYmlRows(): Map { + const doc = yaml.load(readFileSync(this.options.configPath, 'utf8'), { schema: includeYamlSchema }) + if (!Array.isArray(doc)) throw new Error(`dsh web: ${this.options.configPath} is not a top-level entry list`) + const rows = new Map() + for (const row of doc as { id?: string; config?: unknown }[]) { + if (typeof row.id === 'string') rows.set(row.id, row) + } + return rows + } + + /** Profile json under cwd; read-only — never created here, absent = no user config. */ + private readProfile(): Record { + let raw: string + try { + raw = readFileSync(join(process.cwd(), PROFILE_DIR, PROFILE_FILE), 'utf8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {} + throw error + } + const parsed: unknown = JSON.parse(raw) + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`dsh web: ${PROFILE_DIR}/${PROFILE_FILE} must hold a JSON object`) + } + return parsed as Record + } + + /** Dist location is workspace knowledge of this app: resolved through the frontend package exports, not configured. */ + private resolveDistIndex(): string { + const require = createRequire(import.meta.url) + try { + return require.resolve('@deepseek-ai/dsh-frontend/dist/index.html') + } catch { + throw new Error('dsh web: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first') + } + } +} diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 7518804ebb..bcd1482df3 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -1,160 +1,66 @@ /** - * `dsh web` — the web-shape assembly: startHost + dist resolution + - * startWebServer + the URL line + signal wiring. Mixing host and carrier - * concerns is this app module's job (packages stay single-sided). + * `dsh web` — thin bin over the config-tree boot: parse argv, run + * AppCLIEntry, print the URL line, wire signals. All composition lives in + * cordis.yml; all boot glue lives in AppCLIEntry. */ import { parseArgs } from 'node:util' import { networkInterfaces } from 'node:os' -import { createRequire } from 'node:module' -import { mountWebPlugins, startHost } from '@deepseek-ai/dsh-host-runtime' -import { createHostWebPluginRegistry, startWebServer } from '@deepseek-ai/dsh-host-webserver' +import { fileURLToPath } from 'node:url' +import { AppCLIEntry } from './app-cli-entry.ts' const LOOPBACK_HOST = '127.0.0.1' const ALL_INTERFACES_HOST = '0.0.0.0' -// --- Client composition (composition decisions live in the composing app) --- -// The composition layer owns one decision: which plugin packages mount (the -// roster). Dependency edges and the boot prefetch tier live in each package's -// dshClient declaration. - -/** - * Dev-only plugin: the client HMR driver. Whether it composes in is a - * deployment decision — the dev graph includes its row, the prod graph does - * not mount it at all. - */ -const CLIENT_HMR_ID = '@deepseek-ai/dsh-client-hmr' - -/** Bundle stat-poll interval for --dev (held here so the startup log states the real value). */ -const CLIENT_BUNDLE_POLL_MS = 500 - -/** The client plugin roster (flat; per-row boot behavior comes from manifests). */ -const CLIENT_PACKAGES = [ - '@deepseek-ai/dsh-client-connection', - '@deepseek-ai/dsh-client-runtime', - '@deepseek-ai/dsh-client-ui-theme', - '@deepseek-ai/dsh-client-i18n', - '@deepseek-ai/dsh-client-ui-layout', - '@deepseek-ai/dsh-client-ui-sidebar', - '@deepseek-ai/dsh-client-ui-conversation', - '@deepseek-ai/dsh-client-ui-question', - '@deepseek-ai/dsh-client-ui-trajectory', -] as const +const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url)) export async function runWeb(argv: string[]): Promise { const { values } = parseArgs({ args: argv, options: { - host: { type: 'string', default: LOOPBACK_HOST }, - port: { type: 'string', default: '3080' }, + host: { type: 'string' }, + port: { type: 'string' }, dev: { type: 'boolean', default: false }, }, allowPositionals: false, }) - if (values.host !== LOOPBACK_HOST && values.host !== ALL_INTERFACES_HOST) { + if (values.host !== undefined && values.host !== LOOPBACK_HOST && values.host !== ALL_INTERFACES_HOST) { process.stderr.write( `dsh web: invalid --host ${values.host}; expected ${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST}\n`, ) process.exit(1) } - const hostAddress = values.host - const port = Number(values.port) - if (!Number.isInteger(port) || port < 0 || port > 65535) { - process.stderr.write(`dsh web: invalid --port ${values.port}\n`) - process.exit(1) - } - - // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design). - const host = await startHost({ - boot: { - persistenceRoot: './.sessions', - workspaceContext: { maxBytes: 65_536 }, - sessionTitleLlm: true, - }, - }) - - // Client plugin chain: in-memory Loader tree over the composed roster, then - // the registry that feeds the __DSH_BOOT__ entry graph and - // /plugins//client.js. All row content comes from dshClient discovery - // over the mounted roster (dev adds the HMR driver row and turns on the - // bundle watch that drives rebuilt frames). - const roster = [...CLIENT_PACKAGES, ...values.dev ? [CLIENT_HMR_ID] : []] - const mounted = await mountWebPlugins(host.ctx, roster, import.meta.url) - const webPlugins = createHostWebPluginRegistry({ - ctx: host.ctx, - loader: mounted.loader, - resolvePkgJson: mounted.resolvePkgJson, - onError: (err: Error) => { process.stderr.write(`dsh web: plugin rescan: ${String(err)}\n`) }, - ...values.dev ? { watch: { intervalMs: CLIENT_BUNDLE_POLL_MS } } : {}, - }) - if (values.dev) { - // Dev visibility (the registry is a library and never prints): list what - // the bundle watch covers, then log every observed rebuild. This is a - // second onRebuilt subscription — the SSE relay inside the webserver is - // unaffected (multicast). - const revs = new Map(webPlugins.graph().entries.map(row => [row.id, row.rev])) - const bundlePaths = [...revs.keys()] - .map(id => webPlugins.clientPath(id)) - .filter((path): path is string => path !== undefined) - console.log( - `dsh web: watching ${String(bundlePaths.length)} plugin bundles (${String(CLIENT_BUNDLE_POLL_MS)}ms poll):\n ${bundlePaths.join('\n ')}`, - ) - webPlugins.onRebuilt((id, rev) => { - console.log(`dsh web: plugin rebuilt: ${id} rev ${revs.get(id) ?? '?'} -> ${rev}`) - revs.set(id, rev) - }) - } - // Published so the webserver invariant companion can audit manifest/bundle - // consistency; nothing else reads this key. - host.ctx.reflect.provide('webPlugins', webPlugins) - - // Dist location is workspace knowledge of this app: resolved through - // @deepseek-ai/dsh-frontend's package exports, not configured. - const require = createRequire(import.meta.url) - let distIndex: string - try { - distIndex = require.resolve('@deepseek-ai/dsh-frontend/dist/index.html') - } catch { - process.stderr.write('dsh web: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first\n') - await host.dispose() - process.exit(1) - } - - let exiting = false - async function shutdown(code: number): Promise { - if (exiting) return - exiting = true - try { - await server.close() - await host.dispose() - } finally { - process.exit(code) + let port: number | undefined + if (values.port !== undefined) { + port = Number(values.port) + if (!Number.isInteger(port) || port < 0 || port > 65535) { + process.stderr.write(`dsh web: invalid --port ${values.port}\n`) + process.exit(1) } } - let server: Awaited> - try { - server = await startWebServer( - { host: hostAddress, port, distIndex, apiHandler: host.handler, webPlugins }, - (err: Error) => { - process.stderr.write(`dsh web: ${String(err)}\n`) - void shutdown(1) - }, - ) - } catch (error: unknown) { - // listen failed (EADDRINUSE…): no server to close, dispose the host directly. - process.stderr.write(`dsh web: ${String(error)}\n`) - await host.dispose() - process.exit(1) + const entry = new AppCLIEntry({ + configPath: CONFIG_PATH, + dev: values.dev, + ...values.host !== undefined ? { host: values.host } : {}, + ...port !== undefined ? { port } : {}, + }) + const { ctx, port: boundPort } = await entry.run() + + let exiting = false + const shutdown = (code: number): void => { + if (exiting) return + exiting = true + void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) }) } - const lan = hostAddress === ALL_INTERFACES_HOST + const lanCandidate = values.host === ALL_INTERFACES_HOST ? Object.values(networkInterfaces()).flat() .find(iface => iface !== undefined && iface.family === 'IPv4' && !iface.internal) : undefined - const localUrl = `http://${LOOPBACK_HOST}:${server.port}` - console.log(`dsh web: ${localUrl}${lan === undefined ? '' : ` (LAN: http://${lan.address}:${server.port})`}`) + const localUrl = `http://${LOOPBACK_HOST}:${boundPort}` + console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate.address}:${boundPort})`}`) - process.on('SIGTERM', () => { void shutdown(0) }) - process.on('SIGINT', () => { void shutdown(130) }) + process.on('SIGTERM', () => { shutdown(0) }) + process.on('SIGINT', () => { shutdown(130) }) } diff --git a/apps/web/package.json b/apps/web/package.json index a6b5a9b43f..3c8f90b6a0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -24,7 +24,6 @@ "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-web-react": "workspace:^", - "@deepseek-ai/dsh-host-webserver": "workspace:^", "@types/node": "^22.0.0", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts index 16ae6e9ed9..feb85db15a 100644 --- a/apps/web/src/main.ts +++ b/apps/web/src/main.ts @@ -3,8 +3,8 @@ * loader holding, module-table seeding, AppRoot gate, plugin assembly — lives * in @deepseek-ai/dsh-client-web; this file only finds the mount point. */ -import { bootWebShell } from '@deepseek-ai/dsh-client-web' +import { AppWebEntry } from '@deepseek-ai/dsh-client-web' const el = document.getElementById('root') if (el === null) throw new Error('web app: missing #root') -bootWebShell(el) +void new AppWebEntry(el).run() diff --git a/apps/web/tests/session-title.snapshot.ts b/apps/web/tests/session-title.snapshot.ts index 78023d5455..673e92f9ce 100644 --- a/apps/web/tests/session-title.snapshot.ts +++ b/apps/web/tests/session-title.snapshot.ts @@ -3,8 +3,8 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react' import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules' -import { bootWebShell } from '@deepseek-ai/dsh-client-web' +import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client' +import { AppWebEntry } from '@deepseek-ai/dsh-client-web' const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, @@ -81,13 +81,15 @@ it('projects initial and revised durable titles through the built eight-plugin f const root = document.querySelector('#root') if (root === null) throw new Error('snapshot root missing') act(() => { - unmount = bootWebShell(root, { + const entry = new AppWebEntry(root, { fetchBundle: (url) => { const code = bundles.get(url) return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code) }, executeBundle: (code) => { (0, eval)(code) }, }) + void entry.run() + unmount = () => { entry.dispose() } }) const projectLabel = await screen.findByText('fixture', {}, { timeout: 10_000 }) diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts deleted file mode 100644 index 0726d14c8b..0000000000 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ /dev/null @@ -1,291 +0,0 @@ -// Keyless boot-chain smoke over the REAL carrier: startWebServer + entry -// graph (__DSH_BOOT__ web2 shape) injection + built shell dist in a real -// chromium. First describe: graph injection + the fail-loud half. Second -// describe: the settled success pass — all nine REAL tsdown bundles load -// through the module system + vendored Loader chain in ?fixture mode (the -// infrastructure four ride the immediately prefetch tier, the UI rows fetch -// on demand), the three-column frame appears in one flip, and the resident -// question completes through the real UI stack. The full model round lands -// in smoke-real under the W5 real-host standard. -import { existsSync } from 'node:fs' -import { fileURLToPath } from 'node:url' -import type { Browser, Page } from 'playwright' -import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' -import { startWebServer } from '@deepseek-ai/dsh-host-webserver' -import type { WebBootEntry, WebBootGraph } from '@deepseek-ai/dsh-host-webserver' -import { DIST_INDEX, probeFreePort, requireDist, saveFailureShot } from './support.ts' - -const bundlePath = (dir: string): string => - fileURLToPath(new URL(`../../../packages/client/${dir}/lib/client.js`, import.meta.url)) - -const LAYOUT_ID = '@deepseek-ai/dsh-client-ui-layout' -const SIDEBAR_ID = '@deepseek-ai/dsh-client-ui-sidebar' - -/** id ↔ bundle table for the success pass (the complete Web UI assembly). */ -const REAL_PLUGINS: { id: string; dir: string; inject?: string[]; immediately?: boolean }[] = [ - { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', immediately: true }, - { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, - { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', immediately: true }, - { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', immediately: true }, - { id: LAYOUT_ID, dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] }, - { id: SIDEBAR_ID, dir: 'ui-sidebar', inject: [LAYOUT_ID] }, - { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', inject: [LAYOUT_ID] }, - { id: '@deepseek-ai/dsh-client-ui-question', dir: 'ui-question', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, - { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, -] - -const BUNDLE_PATHS = new Map(REAL_PLUGINS.map(p => [p.id, bundlePath(p.dir)])) - -const row = (id: string, extra?: Partial): WebBootEntry => - ({ id, url: `/plugins/${id}/client.js?rev=e2e`, rev: 'e2e', ...extra }) - -const graphRows: WebBootEntry[] = REAL_PLUGINS.map(p => row(p.id, { - ...(p.inject !== undefined ? { inject: p.inject } : {}), - ...(p.immediately === true ? { immediately: true } : {}), -})) - -/** Graph for the fail-loud half: the immediately tier, one live UI row, one missing row. */ -const FAIL_GRAPH: WebBootGraph = { - rev: 'e2e-fail', - entries: [...graphRows.filter(r => r.immediately === true), row(LAYOUT_ID), row('@probe/absent')], -} - -/** Graph for the success pass: the complete assembly. */ -const OK_GRAPH: WebBootGraph = { rev: 'e2e-ok', entries: graphRows } - -/** Registry stub over a fixed graph (the real HostWebPluginRegistry is webserver-side production code). */ -function fixedRegistry(graph: WebBootGraph, byId: ReadonlyMap) { - return { - graph: () => graph, - clientPath: (id: string) => byId.get(id), - onRebuilt: () => () => undefined, - } -} - -describe('web boot chain (keyless, real carrier)', () => { - let server: Awaited> - let browser: Browser - let page: Page - const pageErrors: string[] = [] - - beforeAll(async () => { - requireDist() - const port = await probeFreePort() - const apiHandler = { fetch: () => Promise.resolve(new Response('boot smoke must not call /api', { status: 500 })) } - server = await startWebServer({ - host: '127.0.0.1', - port, - distIndex: DIST_INDEX, - apiHandler, - webPlugins: fixedRegistry(FAIL_GRAPH, BUNDLE_PATHS), - }, (err) => { pageErrors.push(`server: ${String(err)}`) }) - browser = await chromium.launch() - page = await browser.newPage() - page.on('pageerror', e => pageErrors.push(String(e))) - await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'load' }) - }) - - afterAll(async () => { - await browser?.close() - await server?.close() - }) - - it('GET / injects the entry graph verbatim', async () => { - onTestFailed(() => saveFailureShot(page, 'smoke-boot-manifest')) - const boot = await page.evaluate(() => (window as { __DSH_BOOT__?: unknown }).__DSH_BOOT__) - expect(boot).toEqual(FAIL_GRAPH) - }) - - it('serves a real bundle through the plugins endpoint', async () => { - const res = await page.request.get(`${new URL(page.url()).origin}/plugins/${LAYOUT_ID}/client.js`) - expect(res.status()).toBe(200) - expect(await res.text()).toContain('window.__ModuleLoader__.load') - }) - - it('boots to the loading page and fail-louds the absent entry', async () => { - onTestFailed(() => saveFailureShot(page, 'smoke-boot-fail-loud')) - await page.waitForSelector('text=HARNESS', { timeout: 10_000 }) - await page.waitForSelector('text=Failed to load plugins', { timeout: 10_000 }) - await page.waitForSelector('text=@probe/absent', { timeout: 2000 }) - // The real UI must not have flipped in: the gate opens only on settled. - expect(await page.locator('[class*="frame"]').count()).toBe(0) - }) - - it('applies the token sheets before any plugin CSS', async () => { - const family = await page.evaluate(() => getComputedStyle(document.body).getPropertyValue('--dsw-font-family')) - expect(family.trim().length).toBeGreaterThan(0) - }) -}) - -describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', () => { - let server: Awaited> - let browser: Browser - let page: Page - const pageErrors: string[] = [] - - beforeAll(async () => { - requireDist() - const missing = REAL_PLUGINS.filter(p => !existsSync(bundlePath(p.dir))) - if (missing.length > 0) throw new Error(`client bundles not built (pnpm --filter bundle): ${missing.map(m => m.dir).join(', ')}`) - const port = await probeFreePort() - // ?fixture never opens HTTP streams; /api is a tripwire like the first describe. - const apiHandler = { fetch: () => Promise.resolve(new Response('fixture mode must not call /api', { status: 500 })) } - server = await startWebServer({ - host: '127.0.0.1', - port, - distIndex: DIST_INDEX, - apiHandler, - webPlugins: fixedRegistry(OK_GRAPH, BUNDLE_PATHS), - }, (err) => { pageErrors.push(`server: ${String(err)}`) }) - browser = await chromium.launch() - page = await browser.newPage() - page.on('pageerror', e => pageErrors.push(String(e))) - await page.goto(`http://127.0.0.1:${port}/?fixture`, { waitUntil: 'load' }) - }) - - afterAll(async () => { - await browser?.close() - await server?.close() - }) - - it('settles and flips to the three-column frame in one pass', async () => { - onTestFailed(() => saveFailureShot(page, 'smoke-boot-settled')) - await page.waitForSelector('[class*="frame"]', { timeout: 15_000 }) - // Loading page is gone; the grid carries the three tracks. - expect(await page.locator('text=Failed to load plugins').count()).toBe(0) - const template = await page.locator('[class*="frame"]').evaluate(el => getComputedStyle(el).gridTemplateColumns) - expect(template.split(' ').length).toBe(3) - }) - - it('every plugin CSS landed with its ownership tag', async () => { - const owners = await page.evaluate(() => - [...document.querySelectorAll('style[data-plugin]')].map(s => (s as HTMLElement).dataset['plugin'])) - expect(owners).toContain(LAYOUT_ID) - expect(owners).toContain(SIDEBAR_ID) - }) - - it('collapsed sidebar animates to a 56px rail with the four controls', async () => { - onTestFailed(() => saveFailureShot(page, 'smoke-boot-collapsed-rail')) - const frame = page.locator('[class*="frame"]') - const firstTrack = async (): Promise => (await frame.evaluate( - el => getComputedStyle(el).gridTemplateColumns)).split(' ')[0]! - // The tracks transition on the deepsuite curve; assert the animated - // settle rather than an instant jump. - const settledTrack = async (px: string): Promise => { - await expect.poll(firstTrack, { timeout: 2000 }).toBe(px) - } - // The brand wordmark is decorative svg (aria-hidden) — presence tracks the wide chrome. - const brand = () => page.locator('[class*="brand"]').count() - await page.getByRole('button', { name: 'Collapse sidebar' }).click() - // Mid-collapse the wide chrome is still mounted, fading — not swapped out. - expect(await brand()).toBe(1) - await settledTrack('56px') - await expect.poll(brand, { timeout: 2000 }).toBe(0) - for (const name of ['Open sidebar', 'New session', 'New workspace', 'Search sessions', 'Settings']) { - await expect(page.getByRole('button', { name }).isVisible(), name).resolves.toBe(true) - } - await page.getByRole('button', { name: 'Open sidebar' }).click() - await settledTrack('280px') - await expect(page.getByRole('button', { name: 'Collapse sidebar' }).isVisible()).resolves.toBe(true) - // Rail search: collapse again, the search control expands and lands in the box. - await page.getByRole('button', { name: 'Collapse sidebar' }).click() - await settledTrack('56px') - await page.getByRole('button', { name: 'Search sessions' }).click() - await settledTrack('280px') - // Focus is deferred past the slide (EXPAND_SLIDE_MS) — poll for it. - await expect.poll(() => page.evaluate(() => - (document.activeElement as HTMLInputElement | null)?.placeholder ?? ''), { timeout: 2000 }).toContain('Search') - }) - - it('renders file tool rows and expands fixture reasoning from either click target', async () => { - onTestFailed(() => saveFailureShot(page, 'smoke-think-disclosure')) - await page.locator('[role="treeitem"]').first().click() - await page.locator('[role="treeitem"][aria-selected]').first().click() - - const thinkRoot = page.locator('[data-variant="think"]').first() - const think = thinkRoot.getByRole('button') - await think.waitFor({ state: 'visible', timeout: 10_000 }) - expect(await think.getAttribute('aria-expanded')).toBe('false') - - await thinkRoot.getByText(/^思考过程 .*reasoning 内容。$/).click() - expect(await think.getAttribute('aria-expanded')).toBe('true') - expect(await thinkRoot.locator(':scope > div').count()).toBe(2) - - await think.getByText('Think', { exact: true }).click() - expect(await think.getAttribute('aria-expanded')).toBe('false') - - const editRoot = page.locator('[data-variant="edit"]').first() - await editRoot.waitFor({ state: 'visible', timeout: 10_000 }) - expect(await editRoot.getByText('Edit', { exact: true }).count()).toBe(1) - expect(await editRoot.getByText('notes/demo.txt', { exact: true }).count()).toBe(1) - - const writeRoot = page.locator('[data-variant="write"]').first() - await writeRoot.waitFor({ state: 'visible', timeout: 10_000 }) - expect(await writeRoot.getByText('Write', { exact: true }).count()).toBe(1) - expect(await writeRoot.getByText('notes/new-demo.txt', { exact: true }).count()).toBe(1) - }) - - it('keeps Markdown semantic while a fixture reply streams and finalizes', async () => { - onTestFailed(() => saveFailureShot(page, 'smoke-markdown-stream')) - await page.getByRole('button', { name: 'New session', exact: true }).click() - const input = page.locator('textarea[placeholder]') - await input.waitFor({ timeout: 15_000 }) - await input.fill('render markdown') - await page.getByRole('button', { name: '发送' }).click() - - const streaming = page.locator('[data-streaming="true"]') - await streaming.getByRole('heading', { name: 'Markdown fixture' }).waitFor({ timeout: 15_000 }) - await streaming.waitFor({ state: 'detached', timeout: 15_000 }) - - const finalHeading = page.getByRole('heading', { name: 'Markdown fixture' }) - expect(await finalHeading.evaluate(element => element.tagName)).toBe('H1') - expect(await page.locator('pre code').filter({ hasText: 'const markdown = true' }).count()).toBe(1) - const external = page.getByRole('link', { name: 'DeepSeek' }) - expect(await external.getAttribute('target')).toBe('_blank') - expect(await external.getAttribute('rel')).toBe('noopener noreferrer') - }) - - it('renders and completes the resident question through the composer slot', async () => { - onTestFailed(() => saveFailureShot(page, 'smoke-question-composer')) - const sessionTree = page.getByRole('tree', { name: 'Sessions' }) - const projectRow = sessionTree.getByRole('treeitem').filter({ hasText: '3 sessions' }) - if (await projectRow.getAttribute('aria-expanded') === 'false') await projectRow.click() - await sessionTree.getByText('Fixture 历史会话', { exact: true }).click() - const composer = page.locator('[data-question-key]') - await composer.waitFor({ timeout: 15_000 }) - expect({ - question: await composer.getByRole('heading').innerText(), - progress: await composer.getByText('1 / 3', { exact: true }).innerText(), - options: await composer.getByRole('radio').allTextContents(), - custom: await composer.getByRole('button', { name: '其他,请填写自定义答案' }).innerText(), - }).toMatchInlineSnapshot(` - { - "custom": "其他,请填写自定义答案", - "options": [ - "1工程落地型推荐更看重能直接做 runtime、tool executor、sandbox、trace 和线上问题排查。", - "2研究潜力型更看重 Agent 理解、训练评测思路和长期成长空间。", - "3均衡型同时要求工程能力和 Agent 认知,但可能筛选门槛更高。", - ], - "progress": "1 / 3", - "question": "你现在更想招哪类 Agent/Harness 候选人?", - } - `) - - await composer.getByRole('radio', { name: '工程落地型' }).click() - await composer.getByText('2 / 3', { exact: true }).waitFor() - await composer.getByRole('button', { name: '跳过本题', exact: true }).click() - await composer.getByRole('checkbox', { name: '系统设计' }).click() - await composer.getByRole('checkbox', { name: 'Agent 产品判断' }).click() - await composer.getByRole('checkbox', { name: 'Agent 产品判断' }).press('Enter') - - await composer.waitFor({ state: 'detached' }) - const restoredInput = page.locator('textarea[placeholder]') - await restoredInput.waitFor() - expect(await restoredInput.getAttribute('placeholder')).toBe('回复生成中,可停止后再输入') - }) - - it('stayed clean: no page errors across the whole load chain', () => { - expect(pageErrors).toEqual([]) - }) -}) diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index f4fbbb265f..ce0a6db799 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -16,10 +16,7 @@ export function requireDist(): void { } } -/** - * OS-assigned free port, released before use. startWebServer echoes - * options.port instead of the bound one, so passing 0 directly is unusable. - */ +/** OS-assigned free port, released before use (the spawned `dsh web` needs a concrete --port). */ export function probeFreePort(): Promise { return new Promise((resolvePort, reject) => { const probe = createServer() diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 998996304e..d0af0d4641 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -21,9 +21,6 @@ { "path": "../../packages/client/web" }, - { - "path": "../../packages/host/webserver" - }, { "path": "../../packages/client/modules" } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 5a805cbeb3..7043de4911 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -22,7 +22,7 @@ export default defineConfig({ { find: /^@deepseek-ai\/dsh-client-web-react$/, replacement: src('../../packages/client/web-react/src/index.ts') }, { find: /^@deepseek-ai\/dsh-client-ui-slots$/, replacement: src('../../packages/client/ui-slots/src/index.ts') }, { find: /^@deepseek-ai\/dsh-client-ui-primitives$/, replacement: src('../../packages/client/ui-primitives/src/index.ts') }, - { find: /^@deepseek-ai\/dsh-client-modules$/, replacement: src('../../packages/client/modules/src/index.ts') }, + { find: /^@deepseek-ai\/dsh-client-modules\/client$/, replacement: src('../../packages/client/modules/src/client/index.ts') }, ], }, define: { diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 0f96640e00..d66b5332f3 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -125,6 +125,12 @@ flowchart LR svc_spillStore["ctx.spillStore
Spill storage seam"] pkg_spill_local["spill-local"] pkg_spill_policy["spill-policy"] + pkg_webserver["webserver"] + svc_httpServer["ctx.httpServer
HTTP route registration"] + pkg_connection["connection"] + pkg_modules["modules"] + pkg_hmr["hmr"] + svc_clientModuleHost["ctx.clientModuleHost
Client plugin graph host"] pkg_workflow["workflow"] svc_workflows["ctx.workflows
Workflow script engine"] pkg_workflow_workerthread["workflow-workerthread"] @@ -152,6 +158,7 @@ flowchart LR pkg_llm_deepseek --> svc_llm pkg_llm_pi_ai --> svc_llm pkg_llm_replay --> svc_llm + pkg_modules --> svc_clientModuleHost pkg_permission --> svc_permission pkg_plan_mode --> svc_planMode pkg_pty --> svc_pty @@ -193,6 +200,7 @@ flowchart LR pkg_web_search_deepseek --> svc_web pkg_web_search_exa --> svc_web pkg_web_search_perplexity --> svc_web + pkg_webserver --> svc_httpServer pkg_workflow --> svc_workflows pkg_workflow_workerthread --> svc_workflows pkg_workspace --> svc_workspace @@ -207,11 +215,15 @@ flowchart LR svc_bash --> pkg_hooks_claude svc_bash --> pkg_hooks_codex svc_bash --> pkg_tool_bash + svc_clientModuleHost --> pkg_hmr svc_codeRuntime --> pkg_tools svc_commands --> pkg_acp svc_commands --> pkg_tui svc_compact --> pkg_compact_basic svc_fs --> pkg_tool_fs + svc_httpServer --> pkg_connection + svc_httpServer --> pkg_hmr + svc_httpServer --> pkg_modules svc_invariants --> pkg_agent svc_invariants --> pkg_agent_loop svc_invariants --> pkg_scope @@ -318,6 +330,8 @@ flowchart LR | `ctx.tasks` | `core` | [`tasks`](../packages/tasks/tasks) | - | [`tool-bash`](../packages/bash/tool-bash), [`tool-pty`](../packages/pty/tool-pty), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | +| `ctx.httpServer` | `core` | `webserver` | - | `connection`, `modules`, `hmr` | - | Plain node:http carrier: named-route registry, index transform taps, and the static dist fallback; web-transport plugins register their own routes. | +| `ctx.clientModuleHost` | `core` | `modules` | - | `hmr` | - | Composes the __DSH_BOOT__ entry graph from an incremental dshClient scan, serves plugin bundles, and notifies rebuilt/graph-changed subscribers. | | `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/workflow/tool-ralph) | - | One engine per context (bash shape, no named-provider registry); the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents. | Maintenance mode: hybrid: services are discovered from Cordis declarations; interface/implementation/consumer roles are classified in `scripts/gen-doc-graphs.ts` with a completeness guard. diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a9609eb1f9..9037031cef 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -276,6 +276,20 @@ Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · Source: [`packages/examples/cli-demo/src/index.ts:26`](../packages/examples/cli-demo/src/index.ts) +## `@deepseek-ai/dsh-client-hmr` + +Requires: `clientModuleHost` · `httpServer` + +```ts config-catalog +/** Plugin config, validated by the same-named schemastery schema. */ +export interface Config { + /** Bundle stat-poll interval in milliseconds (default 500, the build-side watcher's polling default). */ + pollIntervalMs?: number +} +``` + +Source: [`packages/client/hmr/src/index.ts:30`](../packages/client/hmr/src/index.ts) + ## `@deepseek-ai/dsh-code-runtime-worker` ```ts config-catalog @@ -474,6 +488,38 @@ export interface Config { Source: [`packages/hooks/hooks-codex/src/index.ts:42`](../packages/hooks/hooks-codex/src/index.ts) +## `@deepseek-ai/dsh-host-apiproxy` + +Requires: `agents` · `sessions` · `tools` · `userInteraction` + +```ts config-catalog +/** Gateway plugin config: the host-level default agent routing. */ +export interface Config { + /** Default provider route for created/resumed agents. */ + provider: string + /** Default model id. */ + model: string +} +``` + +Source: [`packages/host/apiproxy/src/index.ts:32`](../packages/host/apiproxy/src/index.ts) + +## `@deepseek-ai/dsh-host-webserver` + +```ts config-catalog +/** Gateway config: listen address plus the static dist anchor (injected by the composing app, never self-resolved). */ +export interface Config { + /** Listen host; the two supported values are loopback and all-interfaces. */ + host: '127.0.0.1' | '0.0.0.0' + /** Listen port; zero requests an OS-assigned port. */ + port: number + /** Absolute path of index.html inside the static root (dist location is workspace knowledge of the app). */ + distIndex: string +} +``` + +Source: [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) + ## `@deepseek-ai/dsh-invariants` ```ts config-catalog @@ -1971,9 +2017,9 @@ Source: [`packages/context/workspace-context/src/config.ts:17`](../packages/cont These load from a `cordis.yml` entry with no `config:` block; they declare no config surface. - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) -- `@deepseek-ai/dsh-client-connection` ([`packages/client/connection/src/index.ts`](../packages/client/connection/src/index.ts)) -- `@deepseek-ai/dsh-client-hmr` ([`packages/client/hmr/src/index.ts`](../packages/client/hmr/src/index.ts)) +- `@deepseek-ai/dsh-client-connection` — requires `httpServer` · `apiProxy` ([`packages/client/connection/src/index.ts`](../packages/client/connection/src/index.ts)) - `@deepseek-ai/dsh-client-i18n` ([`packages/client/i18n/src/index.ts`](../packages/client/i18n/src/index.ts)) +- `@deepseek-ai/dsh-client-modules` — requires `httpServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) - `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) - `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts)) @@ -2022,16 +2068,13 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-agent-loop-testkit` ([`packages/support/agent-loop-testkit/src/index.ts`](../packages/support/agent-loop-testkit/src/index.ts)) - `@deepseek-ai/dsh-app-boot` ([`packages/ui/app-boot/src/index.ts`](../packages/ui/app-boot/src/index.ts)) - `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts)) -- `@deepseek-ai/dsh-client-modules` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) - `@deepseek-ai/dsh-client-ui-primitives` ([`packages/client/ui-primitives/src/index.ts`](../packages/client/ui-primitives/src/index.ts)) - `@deepseek-ai/dsh-client-ui-slots` ([`packages/client/ui-slots/src/index.ts`](../packages/client/ui-slots/src/index.ts)) - `@deepseek-ai/dsh-client-web` ([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts)) - `@deepseek-ai/dsh-client-web-react` ([`packages/client/web-react/src/index.ts`](../packages/client/web-react/src/index.ts)) - `@deepseek-ai/dsh-helper` ([`packages/sdk/helper/src/index.ts`](../packages/sdk/helper/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) -- `@deepseek-ai/dsh-host-apiproxy` ([`packages/host/apiproxy/src/index.ts`](../packages/host/apiproxy/src/index.ts)) - `@deepseek-ai/dsh-host-runtime` ([`packages/host/runtime/src/index.ts`](../packages/host/runtime/src/index.ts)) -- `@deepseek-ai/dsh-host-webserver` ([`packages/host/webserver/src/index.ts`](../packages/host/webserver/src/index.ts)) - `@deepseek-ai/dsh-jsonrpc-demo` ([`packages/examples/jsonrpc-demo/src/index.ts`](../packages/examples/jsonrpc-demo/src/index.ts)) - `@deepseek-ai/dsh-loader-smoke` ([`packages/support/loader-smoke/src/index.ts`](../packages/support/loader-smoke/src/index.ts)) - `@deepseek-ai/dsh-paths` ([`packages/util/paths/src/index.ts`](../packages/util/paths/src/index.ts)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index a4d5f02162..86fc08803c 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -319,6 +319,50 @@ Types: [DshEnvironment](../core-data-structures/bash.md) · [ToolExecution](../c Source: [`packages/bash/tool-bash/src/index.ts:104`](../../packages/bash/tool-bash/src/index.ts) +## `ctx.clientModuleHost` — `ClientModuleHostService` + +The web plugin table service: incremental dshClient 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 sweep reports it). + +```ts cordis-catalog +/** + * 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:143`](../../packages/client/modules/src/index.ts) + ## `ctx.codeRuntime` — `CodeRuntime` (abstract seam) Registers one `ctx.codeRuntime` implementation. Program, budget, abort, and substrate failures resolve in CodeRunResult; only seam misuse rejects. Implementations bridge structured-cloneable bindings, materialize each declared namespace rejection class, treat programs as hostile peers, isolate runs from one another, and terminate and await in-flight runs during disposal. @@ -614,6 +658,30 @@ Types: [Agent](../core-data-structures/core.md) · [CreateGoalRequest](../core-d Source: [`packages/goal/goal/src/index.ts:135`](../../packages/goal/goal/src/index.ts) +## `ctx.httpServer` — `HttpServerService` + +The web-shape HTTP carrier service. Activation listens immediately (route registration order carries no request-facing semantics: named routes are composed to be disjoint, and the static dist fallback answers anything not yet claimed during the boot window). A listen failure throws out of init — a FAILED fiber the boot's fail-loud sweep reports. + +```ts cordis-catalog +/** + * Register a named route. Duplicate (kind, path) throws — route patterns are + * a composition-level contract, so a collision is a misconfiguration. + * @param route - kind, path, and the owning handler. + * @returns the disposer removing the route. + */ +register(route: WebRoute): () => void + +/** + * Register an index.html transform, applied to every index response in + * registration order. + * @param transform - pure html-to-html function. + * @returns the disposer removing the transform. + */ +tapIndex(transform: (html: string) => string): () => void +``` + +Source: [`packages/host/webserver/src/index.ts:55`](../../packages/host/webserver/src/index.ts) + ## `ctx.invariants` — `InvariantService` Package-owned invariant registry with global and regex-based selection. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d0554a1814..16de2600af 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -11,7 +11,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:350`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:294`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:498`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:498`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | | `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:340`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | | `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | @@ -22,7 +22,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:463`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | | `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:424`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | | `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:363`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:303`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:303`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:436`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:474`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode) | | `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:485`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | @@ -34,9 +34,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `runtime`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:111`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | @@ -61,7 +61,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event string | Dispatchers | Listeners | | --- | --- | --- | | `internal/dispatch` | - | [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | -| `internal/plugin` | - | `webserver` | +| `internal/plugin` | - | `hmr`, `modules`, `webserver` | | `internal/status` | - | [`agent`](../packages/core/agent) | | `slots/changed` | `runtime` (`emit`) | - | diff --git a/docs/module-graph.md b/docs/module-graph.md index ab9a2772b5..1f359eef83 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -223,7 +223,6 @@ flowchart TD pkg_subagent_subprocess --> pkg_invariants pkg_acp_snapshot --> pkg_invariants pkg_loader_smoke --> pkg_invariants - pkg_client_connection --> pkg_invariants pkg_client_i18n --> pkg_invariants pkg_client_modules --> pkg_invariants pkg_client_runtime --> pkg_invariants @@ -242,7 +241,10 @@ flowchart TD pkg_storage --> pkg_invariants pkg_llm --> pkg_brand pkg_llm --> pkg_invariants + pkg_client_connection --> pkg_host_webserver + pkg_client_connection --> pkg_invariants pkg_client_hmr --> pkg_client_modules + pkg_client_hmr --> pkg_host_webserver pkg_client_hmr --> pkg_invariants pkg_client_ui_conversation --> pkg_client_runtime pkg_client_ui_conversation --> pkg_client_ui_primitives @@ -811,7 +813,6 @@ flowchart TD | [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | `subagent` | [`invariants`](../packages/support/invariants) | | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`invariants`](../packages/support/invariants) | -| [`client-connection`](../packages/client/connection) | `client` | [`invariants`](../packages/support/invariants) | | [`client-i18n`](../packages/client/i18n) | `client` | [`invariants`](../packages/support/invariants) | | [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) | | [`client-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants) | @@ -829,7 +830,8 @@ flowchart TD | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) | | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | -| [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`invariants`](../packages/support/invariants) | +| [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | +| [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | diff --git a/knip.json b/knip.json index 59658e1df6..713e171a72 100644 --- a/knip.json +++ b/knip.json @@ -56,12 +56,8 @@ ] }, "packages/host/webserver": { - "entry": [ - "tests/**/*.spec.ts" - ], "project": [ - "src/**/*.ts", - "tests/**/*.ts" + "src/**/*.ts" ] }, "packages/host/runtime": { @@ -579,7 +575,8 @@ "src/**/*.ts" ], "ignoreDependencies": [ - "@deepseek-ai/dsh-client-.+" + "@deepseek-ai/.+", + "@cordisjs/.+" ] }, "packages/client/modules": { diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 778b431b71..c26cafb143 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -43,10 +43,12 @@ "src" ], "peerDependencies": { + "@deepseek-ai/dsh-host-webserver": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/client/connection/src/api-path.ts b/packages/client/connection/src/api-path.ts new file mode 100644 index 0000000000..30e91522a2 --- /dev/null +++ b/packages/client/connection/src/api-path.ts @@ -0,0 +1,8 @@ +/** + * The /api URL prefix — single source for both halves of the web transport. + * The node half registers this prefix on the web server; browser-side path + * literals currently live in the apiproxy client layer (out of scope here). + */ + +/** Route prefix owning every api request (`/api` and `/api/`). */ +export const API_PATH = '/api' diff --git a/packages/client/connection/src/http-bridge.ts b/packages/client/connection/src/http-bridge.ts new file mode 100644 index 0000000000..319d3e0b0b --- /dev/null +++ b/packages/client/connection/src/http-bridge.ts @@ -0,0 +1,59 @@ +/** + * node:http ↔ WHATWG fetch bridge for the /api transport (host side of the + * web carrier; the fetch-shaped handler itself is transport-agnostic). + */ + +import type { IncomingMessage, ServerResponse } from 'node:http' + +/** + * Bridge one node:http request to the fetch-shaped handler (client close + * aborts; SSE bodies stream out chunk by chunk). + * @param req - incoming node:http request (fully read before dispatch). + * @param res - node:http response the bridge writes and owns to completion. + * @param apiHandler - fetch-shaped API carrier the request is dispatched to. + */ +export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: { fetch: typeof fetch }): Promise { + const abort = new AbortController() + // Client-disconnect detection MUST hang off the response, not the request: + // since Node 16, IncomingMessage 'close' fires as soon as the request body is + // fully consumed (immediately for a bodyless GET), which would abort every SSE + // stream right after open. ServerResponse 'close' fires on connection teardown; + // writableEnded distinguishes a normal end() from the client going away. + res.on('close', () => { + if (!res.writableEnded) abort.abort() + }) + const chunks: Buffer[] = [] + for await (const chunk of req) chunks.push(chunk as Buffer) + /* v8 ignore next 3 -- `??` arms: node:http always sets url/method on server + requests; the fields are only optional on the client-side IncomingMessage type */ + const request = new Request(new URL(req.url ?? '/', 'http://dsh.internal'), { + method: req.method ?? 'GET', + headers: Object.fromEntries(Object.entries(req.headers).filter(([, v]) => typeof v === 'string') as [string, string][]), + ...chunks.length > 0 ? { body: Buffer.concat(chunks) } : {}, + signal: abort.signal, + }) + const response = await apiHandler.fetch(request) + res.writeHead(response.status, Object.fromEntries(response.headers.entries())) + if (response.body === null) { + res.end() + return + } + for await (const chunk of response.body) { + // Backpressure: a false return means the socket buffer is full — wait for drain + // instead of buffering unboundedly (slow/suspended SSE consumers). 'close' also + // resolves so a mid-wait disconnect can't park this loop forever; the close + // handler above aborts the handler stream, which then ends the iteration. + if (!res.write(chunk)) { + await new Promise((resolve) => { + const done = (): void => { + res.off('drain', done) + res.off('close', done) + resolve() + } + res.once('drain', done) + res.once('close', done) + }) + } + } + res.end() +} diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 313db07225..61d718b618 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -1,10 +1,36 @@ /** - * Connection plugin, node half. The package IS a dshClient plugin: the wire - * consumer layer lives in its client half in full (src/client/ — contract: - * api-contracts v3 section 3, inventory §3.2); consumers import the /client - * subpath. The empty apply exists so the plugin appears in the host Loader - * (lifecycle governance + dshClient discovery). + * Connection plugin, node half: the host end of the web transport. Registers + * the /api prefix route on the web server and bridges node:http requests to + * the transport-agnostic fetch-shaped api handler. The wire consumer layer + * lives in the client half (src/client/ — contract: api-contracts v3 + * section 3); consumers import the /client subpath. */ +import type { Context } from 'cordis' +// Type-only route import; it also carries the httpServer Context merge. +import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' +import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' +import { API_PATH } from './api-path.ts' +import { bridge } from './http-bridge.ts' -/** Host plugin body — no host-side behavior for the connection plugin. */ -export function apply(_ctx: unknown): void {} +export { API_PATH } from './api-path.ts' + +/** Cordis plugin name. */ +export const name = 'client-connection' + +/** Required services: the route registry and the api gateway. */ +export const inject = ['httpServer', 'apiProxy'] + +/** + * Mount the /api transport: wrap the api gateway into a fetch handler and + * serve it under the /api prefix. + * @param ctx - host plugin context carrying httpServer and apiProxy. + */ +export function apply(ctx: Context): void { + const apiHandler = toFetchHandler(ctx.apiProxy) + const route: WebRoute = { + kind: 'prefix', + path: API_PATH, + handler: (req, res) => bridge(req, res, apiHandler), + } + ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route') +} diff --git a/packages/client/connection/src/invariant.ts b/packages/client/connection/src/invariant.ts index df16e00fd4..1112a4e638 100644 --- a/packages/client/connection/src/invariant.ts +++ b/packages/client/connection/src/invariant.ts @@ -15,10 +15,11 @@ export const name = 'client-connection-invariant' export const inject = ['invariants'] /** - * No runtime invariant: the pure wire layer emits no cordis events and owns no + * No runtime invariant: the wire layer emits no cordis events and owns no * mutable cross-plugin relation — stream/reconnect sequencing is exercised - * directly by its behavior specs, and rpcId round-trip discipline is owned by - * the apiproxy contract layer. + * directly by its behavior specs, rpcId round-trip discipline is owned by the + * apiproxy contract layer, and the node half's single route registration's + * register/dispose symmetry is audited by the webserver package's invariant. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index efba1b0445..e9e880cfb4 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -1,10 +1,33 @@ -/** Node half: the empty host apply (Loader governance + dshClient discovery placeholder). */ +/** Node half: registers the /api prefix route bridging to the api gateway. */ +import { Context } from 'cordis' import { describe, expect, it } from 'vitest' -import { apply } from '../src/index.ts' +import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api' +import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver' +import { API_PATH, apply, inject } from '../src/index.ts' -describe('node half', () => { - it('apply is a no-op host placeholder', () => { - apply(undefined) - expect(true).toBe(true) // reaching here without throw is the contract +describe('connection node half', () => { + it('registers the /api prefix route and removes it with the fiber', async () => { + const ctx = new Context() + const routes: WebRoute[] = [] + // Structural fake: the plugin only touches register(); the service class + // carries private state a literal cannot (and need not) reproduce. + const httpServer: Pick = { + register(route) { + routes.push(route) + return () => { routes.splice(routes.indexOf(route), 1) } + }, + tapIndex: () => () => {}, + port: 0, + } + ctx.provide('httpServer', httpServer as HttpServerService) + ctx.provide('apiProxy', {} as unknown as ApiProxy) + + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(routes).toHaveLength(1) + expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH }) + + await fiber.dispose() + expect(routes).toHaveLength(0) }) }) diff --git a/packages/client/connection/tsconfig.json b/packages/client/connection/tsconfig.json index 8b0357cf97..97d020dc53 100644 --- a/packages/client/connection/tsconfig.json +++ b/packages/client/connection/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../../tsconfig.base.client.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/types" + "outDir": "lib/types", + "types": ["node"] }, "include": [ "src" @@ -20,6 +21,9 @@ { "path": "../../host/apiproxy" }, + { + "path": "../../host/webserver" + }, { "path": "../../ui/user-approval" }, diff --git a/packages/client/hmr/package.json b/packages/client/hmr/package.json index 129a7e879a..0773a1fce5 100644 --- a/packages/client/hmr/package.json +++ b/packages/client/hmr/package.json @@ -28,15 +28,20 @@ "immediately": true }, "license": "BSD-3-Clause", + "dependencies": { + "schemastery": "^3.18.0" + }, "peerDependencies": { "@cordisjs/plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-client-modules": "^0.0.1", + "@deepseek-ai/dsh-host-webserver": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", + "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.7" }, diff --git a/packages/client/hmr/src/client/index.ts b/packages/client/hmr/src/client/index.ts index 21df48b561..eae29e8db3 100644 --- a/packages/client/hmr/src/client/index.ts +++ b/packages/client/hmr/src/client/index.ts @@ -64,20 +64,11 @@ */ import type { Context } from 'cordis' import type { Entry, Loader } from '@cordisjs/plugin-loader' -import type { WebBootGraph } from '@deepseek-ai/dsh-client-modules' +import type { PluginsEventFrame } from '../events.ts' +import { EVENTS_ENDPOINT } from '../events.ts' -/** - * Frames on the `GET /plugins/events` system SSE channel (owned host-side by - * dsh-host-webserver's PluginEventFrame). Mirrored here because this is a - * wire boundary: frames arrive as JSON text and are validated at the parse - * point, not shared as a same-process typed seam. - */ -export type PluginsEventFrame = - | { type: 'graph'; graph: WebBootGraph } - | { type: 'rebuilt'; id: string; rev: string } - -/** System SSE endpoint pushing graph/rebuilt frames (wire protocol constant). */ -export const EVENTS_ENDPOINT = '/plugins/events' +export type { PluginsEventFrame } from '../events.ts' +export { EVENTS_ENDPOINT } from '../events.ts' /** Cordis plugin name. */ export const name = 'client-hmr' diff --git a/packages/client/hmr/src/events.ts b/packages/client/hmr/src/events.ts new file mode 100644 index 0000000000..756bd24074 --- /dev/null +++ b/packages/client/hmr/src/events.ts @@ -0,0 +1,16 @@ +/** + * Wire protocol of the `/plugins/events` dev SSE channel — single source for + * both halves of this package. Frames still cross a wire boundary: the + * browser half validates them at its JSON parse point; sharing the type keeps + * the two ends from drifting, not from parsing. + */ + +import type { WebBootGraph } from '@deepseek-ai/dsh-client-modules' + +/** One SSE frame: the full graph on connect, or one rebuilt bundle notice. */ +export type PluginsEventFrame = + | { type: 'graph'; graph: WebBootGraph } + | { type: 'rebuilt'; id: string; rev: string } + +/** System SSE endpoint pushing graph/rebuilt frames (wire protocol constant). */ +export const EVENTS_ENDPOINT = '/plugins/events' diff --git a/packages/client/hmr/src/index.ts b/packages/client/hmr/src/index.ts index cca3c0ddac..4c97ad8bd1 100644 --- a/packages/client/hmr/src/index.ts +++ b/packages/client/hmr/src/index.ts @@ -1,9 +1,152 @@ /** - * HMR plugin, node half. The package IS a dshClient plugin (dev-only row in - * the host graph): the reload driver lives in its client half in full - * (src/client/); the empty apply exists so the plugin appears in the host - * Loader (lifecycle governance + dshClient discovery). + * HMR plugin, node half: the host end of the dev reload chain. Stat-polls + * every graph row's client bundle (fs.watchFile — polling by design: network + * mounts deliver no inotify events), reports content changes through + * `clientModuleHost.rebuilt(id)`, and serves the `/plugins/events` SSE channel + * broadcasting graph/rebuilt frames to the browser half (src/client/). + * Dev-only row: prod compositions never mount this plugin. */ +import type { Stats } from 'node:fs' +import { unwatchFile, watchFile } from 'node:fs' +import type { ServerResponse } from 'node:http' +import type { Context } from 'cordis' +import z from 'schemastery' +// Empty type imports carry the clientModuleHost/httpServer Context merges. +import type {} from '@deepseek-ai/dsh-client-modules' +import type {} from '@deepseek-ai/dsh-host-webserver' +import type { PluginsEventFrame } from './events.ts' +import { EVENTS_ENDPOINT } from './events.ts' -/** Host plugin body — no host-side behavior for the HMR plugin. */ -export function apply(): void {} +export type { PluginsEventFrame } from './events.ts' +export { EVENTS_ENDPOINT } from './events.ts' + +/** Cordis plugin name. */ +export const name = 'client-hmr' + +/** Required services: the web plugin table and the route registry. */ +export const inject = ['clientModuleHost', 'httpServer'] + +/** Plugin config, validated by the same-named schemastery schema. */ +export interface Config { + /** Bundle stat-poll interval in milliseconds (default 500, the build-side watcher's polling default). */ + pollIntervalMs?: number +} + +export const Config: z = z.object({ + pollIntervalMs: z.number().step(1).min(1).default(500), +}) + +/** Serialize one frame as an SSE data line. */ +function sseData(frame: PluginsEventFrame): string { + return `data: ${JSON.stringify(frame)}\n\n` +} + +/** + * Mount the dev chain: bundle watches, rebuilt reporting, and the SSE channel. + * @param ctx - host plugin context carrying clientModuleHost and httpServer. + * @param config - validated {@link Config}. + */ +export function apply(ctx: Context, config: Config): void { + // schemastery's .default() guarantees the field is set after validation. + const pollIntervalMs = config.pollIntervalMs as number + + // --- bundle watch: one fs.watchFile stat poll per graph row ------------- + const watched = new Map void }>() + + const watchRow = (id: string, path: string): void => { + const listener = (curr: Stats, prev: Stats): void => { + // fs.watchFile fires on any stat delta (atime included); only content + // signals count. An all-zero curr means the file vanished mid-rebuild + // — the completing write fires the next tick, so skipping is safe. + if (curr.mtimeMs === prev.mtimeMs && curr.size === prev.size) return + if (curr.mtimeMs === 0) return + try { + // rebuilt() re-hashes; an unchanged hash stays silent (clientModuleHost + // fires onRebuilt only on a real rev change). A torn read of a + // half-written bundle self-heals on the next poll tick. + ctx.clientModuleHost.rebuilt(id) + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'ENOENT') return // mid-rename window; the completed write fires the next poll tick + ctx.logger.warn(error) + } + } + watchFile(path, { interval: pollIntervalMs, persistent: false }, listener) + watched.set(id, { path, listener }) + } + + // Diff the watch set against the current graph: drop watches for removed + // rows (or rows whose bundle path moved), add watches for new rows. + const syncWatches = (): void => { + const rows = new Map() + for (const row of ctx.clientModuleHost.graph().entries) { + const path = ctx.clientModuleHost.clientPath(row.id) + if (path !== undefined) rows.set(row.id, path) + } + for (const [id, watch] of watched) { + if (rows.get(id) === watch.path) continue + unwatchFile(watch.path, watch.listener) + watched.delete(id) + } + for (const [id, path] of rows) { + if (!watched.has(id)) watchRow(id, path) + } + } + + ctx.effect(() => { + // Initial sync covers rows already in the graph; the subscription covers + // rows arriving later (boot-window activations, including this plugin's + // own row — no self-exemption, a modules/hmr rebuild rides the same chain). + syncWatches() + const unsubscribe = ctx.clientModuleHost.onGraphChanged(syncWatches) + return () => { + unsubscribe() + for (const { path, listener } of watched.values()) unwatchFile(path, listener) + watched.clear() + } + }, 'client-hmr: bundle watches') + + // --- /plugins/events SSE channel ---------------------------------------- + const connections = new Set() + + const connect = (res: ServerResponse): void => { + res.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + 'connection': 'keep-alive', + }) + // Comment line on open so clients/proxies see a live channel even when + // no rebuild ever happens; EventSource frame parsing skips it naturally. + res.write(': connected\n\n') + res.write(sseData({ type: 'graph', graph: ctx.clientModuleHost.graph() })) + connections.add(res) + res.on('close', () => { connections.delete(res) }) + } + + ctx.effect(() => { + const disposeRoute = ctx.httpServer.register({ + kind: 'exact', + path: EVENTS_ENDPOINT, + handler: (req, res) => { + // Named routes match ahead of the carrier's method gate; keep the old + // global 405 semantics for non-GET hits on this endpoint. + if (req.method !== 'GET' && req.method !== 'HEAD') { + res.writeHead(405) + res.end() + return + } + connect(res) + }, + }) + const unsubscribe = ctx.clientModuleHost.onRebuilt((id, rev) => { + const line = sseData({ type: 'rebuilt', id, rev }) + for (const res of connections) res.write(line) + }) + return () => { + unsubscribe() + disposeRoute() + for (const res of connections) res.destroy() + connections.clear() + } + }, 'client-hmr: /plugins/events channel') +} diff --git a/packages/client/hmr/src/invariant.ts b/packages/client/hmr/src/invariant.ts index a4c546c991..6eb962efb9 100644 --- a/packages/client/hmr/src/invariant.ts +++ b/packages/client/hmr/src/invariant.ts @@ -3,8 +3,7 @@ * @module @deepseek-ai/dsh-client-hmr/invariant */ -/* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context, Fiber } from 'cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-hmr' @@ -14,14 +13,42 @@ export const name = 'client-hmr-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] +/** Live fs.watchFile pollers (this package is the composition's only stat-poll user). */ +function statWatchers(): number { + return process.getActiveResourcesInfo().filter(kind => kind === 'StatWatcher').length +} + /** - * No runtime invariant: a dev-only reload driver — it consumes the loader - * entry tree and module cache but owns no events and no cross-plugin mutable - * state; reload correctness (dispose → style removal → re-execute ordering) - * is observable only through the assembled browser runtime, not a host-side - * event relation. + * Owned relation: every bundle stat watcher the node half starts must die + * with its fiber — a surviving poller would keep re-hashing bundles for a + * torn-down dev chain forever. Checked as a baseline delta: the StatWatcher + * count observed at fiber creation must be restored once disposal has drained + * the fiber's effects (`internal/plugin` fires at dispose start; the microtask + * hop lets the disposer queue its unload before `fiber.await()` joins it). + * SSE-connection and listener teardown live inside the same ctx.effect + * disposers, so the watcher count is the relation's observable proxy. */ -const install: InvariantInstaller = () => {} +const install: InvariantInstaller = (ctx, fail) => { + const baselines = new WeakMap() + // Async listener by design: emitPluginDisposed awaits-and-logs returned + // promises, so a violation surfaces loudly instead of unhandled. + // eslint-disable-next-line @typescript-eslint/no-misused-promises + ctx.on('internal/plugin', async (fiber) => { + if (fiber.name !== 'client-hmr') return + if (fiber.uid !== null) { + baselines.set(fiber, statWatchers()) + return + } + const baseline = baselines.get(fiber) + if (baseline === undefined) return + await Promise.resolve() + await fiber.await() + const remaining = statWatchers() + if (remaining > baseline) { + fail(`client-hmr fiber disposed but ${remaining - baseline} bundle stat watcher(s) survived teardown`) + } + }, { global: true }) +} /** * Register this package's invariant companion. @@ -30,4 +57,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/hmr/tests/node-half.spec.ts b/packages/client/hmr/tests/node-half.spec.ts index e340263b7a..df48db92d9 100644 --- a/packages/client/hmr/tests/node-half.spec.ts +++ b/packages/client/hmr/tests/node-half.spec.ts @@ -1,14 +1,116 @@ /** - * Node half of the HMR plugin: an empty apply placeholder (the reload driver - * lives in the client half) whose only contract is mounting and disposing - * cleanly in the host Loader. + * Node half of the HMR plugin: bundle watches follow the graph, stat changes + * report through clientModuleHost.rebuilt, and everything dies with the fiber. */ -import { describe, expect, it } from 'vitest' -import { apply } from '@deepseek-ai/dsh-client-hmr' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from 'cordis' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { WebBootGraph, ClientModuleHostService } from '@deepseek-ai/dsh-client-modules' +import type { WebRoute, HttpServerService } from '@deepseek-ai/dsh-host-webserver' +import { apply, Config, EVENTS_ENDPOINT, inject } from '../src/index.ts' + +const POLL_MS = 20 + +let dir: string + +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-')) }) +afterEach(() => { rmSync(dir, { recursive: true, force: true }) }) + +/** + * Controllable clientModuleHost fake over a mutable id → bundle-path table. + * Structural (Pick+cast): the plugin only touches the read/notify surface; + * the service class carries private scan state a literal need not reproduce. + */ +type FakeHost = ClientModuleHostService & { rebuiltCalls: string[]; fireGraphChanged(): void } +function fakeClientModuleHost(rows: Map): FakeHost { + const graphListeners = new Set<() => void>() + const rebuiltCalls: string[] = [] + const fake: Pick = { + rebuiltCalls, + fireGraphChanged: () => { for (const l of graphListeners) l() }, + graph: (): WebBootGraph => ({ + rev: 'r', + entries: [...rows.keys()].map(id => ({ id, url: `/plugins/${id}/client.js?rev=r`, rev: 'r' })), + }), + clientPath: id => rows.get(id), + rebuilt: (id) => { rebuiltCalls.push(id); return 'r2' }, + onRebuilt: () => () => {}, + onGraphChanged: (listener) => { + graphListeners.add(listener) + return () => { graphListeners.delete(listener) } + }, + } + return fake as FakeHost +} + +// Structural fake: the plugin only touches register(); the service class +// carries private state a literal cannot (and need not) reproduce. +function fakeHttpServer(routes: WebRoute[]): HttpServerService { + const fake: Pick = { + register(route) { + routes.push(route) + return () => { routes.splice(routes.indexOf(route), 1) } + }, + tapIndex: () => () => {}, + port: 0, + } + return fake as HttpServerService +} + +async function mount(clientModuleHost: FakeHost, httpServer: HttpServerService) { + const ctx = new Context() + ctx.provide('clientModuleHost', clientModuleHost) + ctx.provide('httpServer', httpServer) + const fiber = ctx.plugin( + { inject: [...inject], Config, apply }, + { pollIntervalMs: POLL_MS }, + ) + await fiber.await() + return fiber +} describe('hmr node half', () => { - it('apply is a no-op host placeholder', () => { - apply() - expect(true).toBe(true) // reaching here without throw is the contract + it('watches graph bundles, reports stat changes, and unwatches on dispose', async () => { + const bundle = join(dir, 'a.js') + writeFileSync(bundle, 'v1') + const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]])) + const routes: WebRoute[] = [] + const fiber = await mount(clientModuleHost, fakeHttpServer(routes)) + + expect(routes).toHaveLength(1) + expect(routes[0]).toMatchObject({ kind: 'exact', path: EVENTS_ENDPOINT }) + + // Nudge mtime past stat granularity so the poller sees a content signal. + await new Promise(resolve => setTimeout(resolve, POLL_MS * 2)) + writeFileSync(bundle, 'v2-longer') + await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-a') }, { timeout: 3_000 }) + + await fiber.dispose() + expect(routes).toHaveLength(0) + // Watcher gone: further file changes report nothing. + clientModuleHost.rebuiltCalls.length = 0 + writeFileSync(bundle, 'v3-even-longer') + await new Promise(resolve => setTimeout(resolve, POLL_MS * 4)) + expect(clientModuleHost.rebuiltCalls).toHaveLength(0) + }) + + it('follows graph changes: rows added after activation get watched', async () => { + const early = join(dir, 'early.js') + const late = join(dir, 'late.js') + writeFileSync(early, 'v1') + const rows = new Map([['pkg-early', early]]) + const clientModuleHost = fakeClientModuleHost(rows) + const fiber = await mount(clientModuleHost, fakeHttpServer([])) + + writeFileSync(late, 'v1') + rows.set('pkg-late', late) + clientModuleHost.fireGraphChanged() + + await new Promise(resolve => setTimeout(resolve, POLL_MS * 2)) + writeFileSync(late, 'v2-longer') + await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-late') }, { timeout: 3_000 }) + await fiber.dispose() }) }) diff --git a/packages/client/hmr/tsconfig.json b/packages/client/hmr/tsconfig.json index 764741c9cb..9ad1837558 100644 --- a/packages/client/hmr/tsconfig.json +++ b/packages/client/hmr/tsconfig.json @@ -8,7 +8,7 @@ "DOM", "DOM.Iterable" ], - "types": [] + "types": ["node"] }, "include": [ "src" @@ -23,6 +23,12 @@ { "path": "../modules" }, + { + "path": "../../host/webserver" + }, + { + "path": "../../../vendor/schemastery" + }, { "path": "../../support/invariants" } diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json index ad2fb78ab1..468ffdf0bc 100644 --- a/packages/client/modules/package.json +++ b/packages/client/modules/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-modules", - "description": "Client module loader: the browser peer of Node's internal ESM loader, consumed by the vendored cordis Loader as its internal seam (resolve/import/loadCache/invalidate over seed table, static registry and fetch bundles)", + "description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dshClient scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam", "version": "0.0.1", "private": true, "type": "module", @@ -11,6 +11,10 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, "./invariant": { "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" @@ -18,14 +22,26 @@ "./src/*": "./src/*", "./package.json": "./package.json" }, + "dshClient": { + "platform": "web", + "inject": [], + "immediately": true + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, "license": "BSD-3-Clause", "devDependencies": { + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.7" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/client.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" diff --git a/packages/client/modules/src/client/index.ts b/packages/client/modules/src/client/index.ts new file mode 100644 index 0000000000..c734ffb8f7 --- /dev/null +++ b/packages/client/modules/src/client/index.ts @@ -0,0 +1,34 @@ +/** + * Browser half (the standard `./client` export): the module-system class and + * wire contract, plus the enrollment plugin face. The module system itself is + * built by the shell kernel BEFORE cordis exists (the bootstrap exception, + * design §4.7 — the mechanism that loads plugins cannot arrive through + * itself); the plugin face only enrolls that pre-existing instance by + * providing it as `ctx.modules`. The kernel statically registers this module, + * so the graph row for this package never triggers a real fetch — arrival is + * a no-op against the already-registered entry. + * @module @deepseek-ai/dsh-client-modules/client + */ +import type { Context } from 'cordis' +import type { DshWindow } from './manifest.ts' + +export { ClientModuleSystem } from './system.ts' +export { parseBootManifest } from './manifest.ts' +export type { + BootManifest, BootModuleRow, BootPluginRow, ClientModuleLoader, ClientModuleRecord, + ClientModuleSystemOptions, ClientPluginHandoff, DshWindow, WebBootEntry, WebBootGraph, +} from './manifest.ts' + +/** + * Enroll the kernel-built module system as `ctx.modules`. + * @param ctx - client root context. + */ +export function apply(ctx: Context): void { + const modules = (globalThis as DshWindow).__DSH_MODULES__ + // The kernel writes the slot right after constructing the instance, before + // any cordis entry exists — a missing slot means the kernel sequencing broke. + if (modules === undefined) { + throw new Error('client-modules: window.__DSH_MODULES__ missing — the shell kernel must construct the module system before plugin boot') + } + ctx.reflect.provide('modules', modules) +} diff --git a/packages/client/modules/src/client/manifest.ts b/packages/client/modules/src/client/manifest.ts new file mode 100644 index 0000000000..6b8ff35548 --- /dev/null +++ b/packages/client/modules/src/client/manifest.ts @@ -0,0 +1,243 @@ +/** + * Client module system: the browser peer of Node's internal ESM loader, built + * as a lazy CJS table. The vendored cordis Loader consumes this object + * through its `internal` seam (the only call site is `EntryTree.import` → + * `internal.import`), which keeps entry governance (fiber lifecycle, inject + * waiting, update/refresh) entirely on the vendored side while this package + * owns code arrival. + * + * Lazy CJS model (web2 §0): executing a plugin bundle only REGISTERS its + * factory (`window.__ModuleLoader__.load({id, factory})`); every module body + * side effect — including CSS injection — lives inside the factory closure + * and runs at materialization, not at script execution. Materialization + * (factory(require) → export surface) happens on first import/require and is + * memoized in {@link ClientModuleLoader.loadCache}; a factory that requires + * another registered-but-unmaterialized module materializes it recursively, + * so load order needs no external sequencing. + * + * Resolution branch order (import): seed word → shell instance; memoized + * record → surface; static registry (shell-own modules, e.g. app-shell) → + * module; registered factory → materialize; graph row → fetch + execute + + * materialize; anything else → throw (loud — the runtime mirror of the + * build-time bundle purity gate). The synchronous `require` handed to + * factories walks the same order minus the fetch branch: fetching is async, + * so only already-executed bundles can be required — and cross-plugin value + * imports are a build error anyway. + * + * This file is the browser-safe contract face (zero node imports): the + * `__DSH_BOOT__` wire types, the boot-manifest parser, and the seams around + * {@link ClientModuleSystem}. The package root is the host-side service that + * composes the wire. + */ + +import type {} from 'cordis' +import type { ClientModuleSystem } from './system.ts' + +declare module 'cordis' { + interface Context { + /** The client module system the web shell builds at boot (contract C5; provided by the `./client` wrapper plugin). */ + modules: ClientModuleLoader + } +} + +/** + * 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). + */ +export interface WebBootEntry { + /** Entry name == package name. */ + id: string + /** Bundle endpoint, '/plugins//client.js?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__`. */ +export 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[] +} + +/** The npm-package view of one boot row: what the module table needs to fetch the bundle. */ +export interface BootModuleRow { + /** Entry name == package name (module-table key). */ + id: string + /** Bundle endpoint, '/plugins//client.js?rev='. */ + url: string + /** Bundle content hash. */ + rev: string +} + +/** The cordis-plugin view of one boot row: what entry composition needs (optional wire fields normalized). */ +export interface BootPluginRow { + /** Entry name == package name. */ + id: string + /** Package-name dependency edges ([] when the wire omits them). */ + inject: string[] + /** Stage-one prefetch tier (false when the wire omits it). */ + immediately: boolean +} + +/** The parsed boot manifest: one wire, two consumer views. */ +export interface BootManifest { + /** Consistency anchor over the whole graph. */ + rev: string + /** Rows as the module table consumes them. */ + modules: BootModuleRow[] + /** Rows as entry composition consumes them. */ + plugins: BootPluginRow[] +} + +/** + * Parse `window.__DSH_BOOT__` into the two consumer views. Wire boundary: + * a missing or malformed graph throws (the shell shows the loud failure — + * a page without a valid manifest cannot boot anything). + * @param wire - the raw `window.__DSH_BOOT__` value. + * @returns the manifest with optional plugin-view fields normalized. + */ +export function parseBootManifest(wire: unknown): BootManifest { + if (typeof wire !== 'object' || wire === null) { + throw new Error('client-modules: window.__DSH_BOOT__ is missing or not an object') + } + const graph = wire as Record + if (typeof graph.rev !== 'string') { + throw new Error('client-modules: boot manifest rev must be a string') + } + if (!Array.isArray(graph.entries)) { + throw new Error('client-modules: boot manifest entries must be an array') + } + const modules: BootModuleRow[] = [] + const plugins: BootPluginRow[] = [] + for (const value of graph.entries as unknown[]) { + if (typeof value !== 'object' || value === null) { + throw new Error('client-modules: boot manifest entry is not an object') + } + const row = value as Record + const where = typeof row.id === 'string' ? `"${row.id}"` : JSON.stringify(row) + if (typeof row.id !== 'string' || typeof row.url !== 'string' || typeof row.rev !== 'string') { + throw new Error(`client-modules: boot manifest entry ${where} must carry string id/url/rev`) + } + if (row.inject !== undefined && (!Array.isArray(row.inject) || row.inject.some(i => typeof i !== 'string'))) { + throw new Error(`client-modules: boot manifest entry ${where} inject must be a string array`) + } + if (row.immediately !== undefined && typeof row.immediately !== 'boolean') { + throw new Error(`client-modules: boot manifest entry ${where} immediately must be a boolean`) + } + modules.push({ id: row.id, url: row.url, rev: row.rev }) + plugins.push({ + id: row.id, + inject: row.inject === undefined ? [] : [...row.inject as string[]], + immediately: row.immediately === true, + }) + } + return { rev: graph.rev, modules, plugins } +} + +/** The shape a client bundle hands to `window.__ModuleLoader__.load` (registration handoff, contract C6). */ +export interface ClientPluginHandoff { + /** Plugin id (package name) — the registration key; must match the graph row being executed. */ + id: string + /** + * Closure factory holding the whole bundle body: receives the synchronous + * require bound to the module table and returns the bundle's export + * surface. Runs once, at materialization. + */ + factory: (require: (spec: string) => unknown) => Record +} + +/** Window surface of the web boot protocol: the host-injected graph, the registration sink, and the kernel handoff slot. */ +export interface DshWindow { + /** Host-composed entry graph, injected before the shell bundle runs; wire-boundary raw until {@link parseBootManifest}. */ + __DSH_BOOT__?: unknown + /** Bundle registration sink; installed once per page by the {@link ClientModuleSystem} constructor (contract C6). */ + __ModuleLoader__?: { load(handoff: ClientPluginHandoff): void } + /** + * Kernel handoff slot: the shell kernel stores the instance here right + * after construction (before cordis exists) so the `./client` wrapper + * plugin can provide it as `ctx.modules`. Missing slot at wrapper apply + * time = kernel sequencing bug, thrown loud. + */ + __DSH_MODULES__?: ClientModuleSystem +} + +/** Per-module bookkeeping in {@link ClientModuleLoader.loadCache} (module-graph seam, flat today). */ +export interface ClientModuleRecord { + /** Module id (entry name / package name). */ + id: string + /** The materialized export surface (factory `module.exports`, or the shell module for static registrations). */ + surface: unknown + /** Owned `