Merge pull request #578 from deepseek-harness/worktree-webload
This commit is contained in:
+2
-2
@@ -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-19-gui-layering-and-rpc-protocol.md: 65fb01f44698c61e6bf6958e332e1854fbb77fa9
|
||||
2026-07-19-gui-layering-and-rpc-protocol.zh.md: e8b15789846ea124fb6a90f2afef437184d4348a
|
||||
2026-07-19-gui-layering-and-rpc-protocol.md: 63db4786adcc007d09b7a58824a59f4d1e1e8be1
|
||||
2026-07-19-gui-layering-and-rpc-protocol.zh.md: b3037ceb8c172925581d2862ea675e53a7f8c54e
|
||||
@@ -25,9 +25,10 @@ Directories layer as follows:
|
||||
|
||||
- `packages/host/*`: packages provide host-side capability only (representing the Node.js engineering core built on the existing harness plugin system), and additionally
|
||||
- the unified backend protocol (fetch, HTTP, streaming interfaces…) — definitions and support, see the "Message protocol" sections below
|
||||
- `packages/client/*`: packages provide client-side capability only; every package stays single-sided. Two kinds live here:
|
||||
- **Pure libraries** (`ui-slots`, `web-react`, `ui-primitives`): ordinary root-index packages, statically bundled into the shell and seeded into the browser plugin loader's module table.
|
||||
- **dshClient plugin packages** (`connection`, `runtime`, `ui-theme`, `i18n`, `ui-layout`, `ui-sidebar`, `ui-conversation`, `ui-trajectory`): dual-entry — the root index is the node half (an empty `apply`, existing so the host Loader governs lifecycle and the web plugin registry discovers the package.json `dshClient` declaration); the entire implementation and its types live under `src/client/`, shipped as the `./client` subpath (a tsdown closure-factory bundle), and cross-package consumption imports the `/client` form. `runtime` additionally exports `./loader` (the shell-held browser bundle loader — a loader cannot load itself).
|
||||
- `packages/client/*`: packages provide client-side capability only; every package stays single-sided. Three kinds live here (the axes are owned by the [client plugin loading RFC](2026-07-23-client-plugin-loading-model.md)):
|
||||
- **Pure libraries** (`ui-slots`, `web-react`, `ui-primitives`, plus the `loader` kernel package): ordinary root-index packages, statically bundled into the shell; the first three are seeded into the module table.
|
||||
- **Static-arrival entry packages** (`connection`, `runtime`, `ui-theme`, `i18n`, `hmr`): no `dshClient` key and no browser bundle — the shell bundles their `src/client/` half and registers it with `ctx.modules`; they are governed as entries of the host-authored graph like everything else.
|
||||
- **Fetch-arrival plugin packages** (`ui-layout`, `ui-sidebar`, `ui-conversation`, `ui-trajectory`): dual-entry — the root index is the node half (an empty `apply`, existing so the host Loader governs lifecycle and the web plugin registry discovers the package.json `dshClient` declaration); the implementation lives under `src/client/`, shipped as the `./client` subpath (a tsdown closure-factory bundle). Cross-plugin consumption of `/client` is type-only; value cooperation goes through cordis services.
|
||||
- `apps/` holds the externally exported application shapes, assembled from Client / Host mixtures.
|
||||
- `apps/web` (`dsh-frontend`) is the vite application: a thin `main.ts` over the shell surface exported by `dsh-client-web`.
|
||||
- `apps/cli` (`@deepseek-ai/dsh`) dispatches shapes: `dsh web` = startHost + webserver + the built `dsh-frontend` dist; `dsh -p` = headless in-process calls, zero HTTP.
|
||||
@@ -51,7 +52,7 @@ Direction discipline (every rule auditable from package deps):
|
||||
- `runtime → apiproxy` is one-way; apiproxy depends only on type definitions.
|
||||
- Client-side packages **never import** host-side package runtime (they consume only the two browser-safe subpaths `/api` and `/client`).
|
||||
- `webserver` does not depend on `runtime`: it provides a `{ fetch }`-shaped implementation — "webserver ← runtime" is a runtime injection relationship, not a package dependency.
|
||||
- Cross-package client imports use the `/client` subpath for plugin packages (a bare package name would inline a second runtime instance into a browser bundle; the tsdown purity gate rewrites or rejects it).
|
||||
- Cross-package client imports use the `/client` subpath for plugin packages, and between plugin packages they are type-only — a cross-plugin value import is a build error at the tsdown purity gate (value cooperation goes through cordis services; the [client plugin loading RFC](2026-07-23-client-plugin-loading-model.md) owns the edge rules).
|
||||
|
||||
TypeScript checks in **two aggregate programs** referenced by a solution root (`tsconfig.json` = solution; `tsconfig.host.json` = host side + tests, excluding `packages/client`; `tsconfig.client.json` = client packages and their tests): both sides merge the cordis `Context` interface under the same keys (`sessions`, `loader`) with different services, so one program would see both declaration merges and report a collision. Shared leaves (session/llm/tools/apiproxy…) build once and are referenced by both programs ([topology](../process/2026-07-22-tsconfig-solution-root-two-aggregates.md)).
|
||||
|
||||
@@ -70,7 +71,7 @@ On the protocol side: TS interfaces (`packages/host/apiproxy/src/api/`, zero Nod
|
||||
|
||||
#### Naming rule
|
||||
|
||||
Packages under `packages/host/*` and `packages/client/*` **must carry the directory-group prefix in the package name**: host/runtime → `dsh-host-runtime`, client/runtime → `dsh-client-runtime`. The directory name does not repeat the group prefix (host/ already expresses it). The package-name tail therefore ≠ the directory name, so the `dsh-*` wildcard in tsconfig.base.json (which resolves by directory name) misses them — **each package in these two groups needs an explicit paths entry**, including separate entries for the plugin packages' `/client` (and runtime's `/loader`) subpaths so source-level resolution matches the exports map.
|
||||
Packages under `packages/host/*` and `packages/client/*` **must carry the directory-group prefix in the package name**: host/runtime → `dsh-host-runtime`, client/runtime → `dsh-client-runtime`. The directory name does not repeat the group prefix (host/ already expresses it). The package-name tail therefore ≠ the directory name, so the `dsh-*` wildcard in tsconfig.base.json (which resolves by directory name) misses them — **each package in these two groups needs an explicit paths entry**, including separate entries for the client packages' `/client` subpaths so source-level resolution matches the exports map.
|
||||
|
||||
#### How to integrate a new shape (operational checklist)
|
||||
|
||||
|
||||
+6
-5
@@ -23,9 +23,10 @@ Status: implemented
|
||||
目录按照如下分层:
|
||||
- `packages/host/*`: 包只提供 Host 侧能力(代表了以现在 Harness 实体插件系统为主体的 Node.js 代码核心工程),除此之外,还包含
|
||||
- 统一后端协议(fetch、HTTP、流式接口等)定义和支持,见本篇「消息协议」起各节
|
||||
- `packages/client/*`:包只提供 Client 侧能力,每包单边不混。这里住两类包:
|
||||
- **纯库**(`ui-slots`、`web-react`、`ui-primitives`):普通根入口包,静态打包进壳,并播种进浏览器插件 loader 的模块表。
|
||||
- **dshClient 插件包**(`connection`、`runtime`、`ui-theme`、`i18n`、`ui-layout`、`ui-sidebar`、`ui-conversation`、`ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dshClient` 声明);实现与类型全部住在 `src/client/` 下,经 `./client` 子路径发布(tsdown 闭包工厂 bundle),跨包消费一律 import `/client` 形式。`runtime` 额外导出 `./loader`(壳持有的浏览器 bundle loader——loader 加载不了自己)。
|
||||
- `packages/client/*`:包只提供 Client 侧能力,每包单边不混。这里住三类包(两条轴归 [client 插件装载 RFC](2026-07-23-client-plugin-loading-model.md) 所有):
|
||||
- **纯库**(`ui-slots`、`web-react`、`ui-primitives`,外加内核包 `loader`):普通根入口包,静态打包进壳;前三者播种进模块表。
|
||||
- **静态到达 entry 包**(`connection`、`runtime`、`ui-theme`、`i18n`、`hmr`):无 `dshClient` 键、无浏览器 bundle——壳把它们的 `src/client/` 半边打进自己的 bundle 并向 `ctx.modules` 登记;它们与其余单元一样,作为 host 独家撰写的图里的 entry 受治理。
|
||||
- **fetch 到达插件包**(`ui-layout`、`ui-sidebar`、`ui-conversation`、`ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dshClient` 声明);实现住在 `src/client/` 下,经 `./client` 子路径发布(tsdown 闭包工厂 bundle)。跨插件消费 `/client` 只限类型;值层面的协作走 cordis 服务。
|
||||
- `apps/` 作为对外导出的应用形态入口,可以由 Client / Host 混合组装。
|
||||
- `apps/web`(`dsh-frontend`)是 vite 应用:`dsh-client-web` 导出的壳表面之上的一层薄 `main.ts`。
|
||||
- `apps/cli`(`@deepseek-ai/dsh`)做形态分发:`dsh web` = startHost + webserver + 构建出的 `dsh-frontend` dist;`dsh -p` = headless 进程内直调,零 HTTP。
|
||||
@@ -49,7 +50,7 @@ harness core packages ──────────────────┘
|
||||
- `runtime → apiproxy` 单向;apiproxy 仅依赖类型定义。
|
||||
- client 侧包**永不 import** host 侧包的运行时(只吃 `/api`、`/client` 两个浏览器安全子路径)。
|
||||
- `webserver` 不依赖 `runtime`:它提供 `{ fetch }` 特定实现 ——「webserver ← runtime」只是运行时注入关系,不是包依赖。
|
||||
- client 侧跨包 import 插件包一律走 `/client` 子路径(裸包名会把第二份运行时实例内联进浏览器 bundle;tsdown 纯度门禁会改写或拒收)。
|
||||
- client 侧跨包 import 插件包一律走 `/client` 子路径,且插件包之间只限类型 import——跨插件值 import 在 tsdown 纯度门禁处即构建错误(值层面的协作走 cordis 服务;边规则归 [client 插件装载 RFC](2026-07-23-client-plugin-loading-model.md) 所有)。
|
||||
|
||||
TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig.json` = solution;`tsconfig.host.json` = host 侧 + 测试,排除 `packages/client`;`tsconfig.client.json` = client 各包及其测试):两侧在相同键(`sessions`、`loader`)下以不同服务合并 cordis `Context` 接口,单一 program 会同时看到两份声明合并而报冲突。共享叶子包(session/llm/tools/apiproxy 等)只构建一次,由两个 program 共同引用([拓扑](../process/2026-07-22-tsconfig-solution-root-two-aggregates.md))。
|
||||
|
||||
@@ -68,7 +69,7 @@ TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig.
|
||||
|
||||
#### 命名规则
|
||||
|
||||
`packages/host/*` 与 `packages/client/*` 下的包名**必须含目录组前缀**:host/runtime → `dsh-host-runtime`、client/runtime → `dsh-client-runtime`。目录名不重复组前缀(host/ 已表达)。因此包名尾段 ≠ 目录名,tsconfig.base.json 的 `dsh-*` 通配(按目录名解析)命不中——**这两组的每包需显式 paths 条目**,且插件包的 `/client`(以及 runtime 的 `/loader`)子路径要单列条目,使源码级解析与 exports map 一致。
|
||||
`packages/host/*` 与 `packages/client/*` 下的包名**必须含目录组前缀**:host/runtime → `dsh-host-runtime`、client/runtime → `dsh-client-runtime`。目录名不重复组前缀(host/ 已表达)。因此包名尾段 ≠ 目录名,tsconfig.base.json 的 `dsh-*` 通配(按目录名解析)命不中——**这两组的每包需显式 paths 条目**,且 client 各包的 `/client` 子路径要单列条目,使源码级解析与 exports map 一致。
|
||||
|
||||
#### 怎么接入一个新形态(操作清单)
|
||||
|
||||
|
||||
+2
-2
@@ -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-19-gui-web-client-architecture.md: 6e1cbc2d1e3e3437480c8005ca06845c23c628df
|
||||
2026-07-19-gui-web-client-architecture.zh.md: 9e2b3ef60d97840cd6cbd26e8fdcf922d472391c
|
||||
2026-07-19-gui-web-client-architecture.md: eeae5fb3ad8eb3e9842b497ee51258375760bc93
|
||||
2026-07-19-gui-web-client-architecture.zh.md: c6f4b10c2a4c2d210c6bd7a7fa1ac470cab7c0a7
|
||||
@@ -17,29 +17,22 @@ Both ends run cordis. The host is a cordis plugin tree; the browser runs a secon
|
||||
```
|
||||
┌─ Host ─────────────────────────┐ ┌─ Browser ─────────────────────────────────────────┐
|
||||
│ sessions/agents/SessionLog │ │ client cordis root ctx │
|
||||
│ apiproxy: RPC + mux/host 双流 │◀─▶│ ├ loader(壳静态持有,不能经自己装载) │
|
||||
│ webserver: │ │ ├ immediately 先行组: connection/runtime/ │
|
||||
│ ├ GET /plugins/<id>/client.js │ │ │ ui-theme/i18n(动态 bundle,并行先装) │
|
||||
│ └ GET / 注入 __DSH_BOOT__ │ │ ├ 后续组: layout/sidebar/conversation/trajectory │
|
||||
└────────────────────────────────┘ │ └ session scope ×N(观看驱动,惰性建) │
|
||||
│ apiproxy: RPC + mux/host 双流 │◀─▶│ ├ vendored Loader + ctx.modules(内核,壳静态持有)│
|
||||
│ webserver: │ │ ├ immediately entries: connection/runtime/ │
|
||||
│ ├ GET /plugins/<id>/client.js │ │ │ ui-theme/i18n(fetch bundle,boot 预拉) │
|
||||
│ └ GET / 注入 __DSH_BOOT__ 图 │ │ ├ lazy entries: layout/sidebar/ │
|
||||
│ │ │ │ conversation/trajectory(fetch bundle,按需) │
|
||||
└────────────────────────────────┘ │ ├ app-shell 伪行(壳内静态注册,同一治理) │
|
||||
│ └ session scope ×N(观看驱动,惰性建) │
|
||||
│ React: loading 页 → settled → 整 UI 一次成型 │
|
||||
└────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## The client cordis tree and the loading chain
|
||||
|
||||
Every UI plugin is simultaneously a host plugin (dual-entry package): the node half sits in the host's plugin tree so the host Loader governs its lifecycle, and the browser half is a tsdown closure bundle under the package's `exports["./client"]`. The host webserver derives the boot manifest from loaded plugins carrying a `dshClient` manifest field and injects it into the page as `window.__DSH_BOOT__` — the HTML alone tells the browser everything to fetch, zero extra round trips.
|
||||
The loading chain — the two package kinds (plain vs dshClient plugin), the module-system/plugin-governor split, the two-phase boot over the host-authored entry graph with revisions, and hot reload — is owned by the [client plugin loading RFC](2026-07-23-client-plugin-loading-model.md). The load-bearing facts for this document: the browser boots the same vendored `@cordisjs/plugin-loader` as the host with a client module system (`ctx.modules`, `packages/client/modules`) filling its `internal` seam; every unit with product behavior is an entry in the host-authored `__DSH_BOOT__` graph — all nine plugin packages (infrastructure included) carry the `dshClient` declaration and arrive as fetched `./client` tsdown closure bundles, `immediately` rows differing only in boot phase-one prefetch, while plain packages (react family, cordis, the not-yet-promoted libraries) stay shell-bundled, seeded, and invisible to the graph; bundles execute `window.__ModuleLoader__.load({ id, factory })` and their `require` is answered from the lazy CJS module table (seed words + registered factories, materialized and memoized on first require — cross-plugin value imports are a build error, cooperation goes through cordis services); plugin CSS is inlined in the bundle and injected as `<style data-plugin="<id>">` at materialization (CSS Modules hashing + ownership tag = isolation, removal on reload); hot reload is live in dev graphs — the webserver stat-polls the bundles it serves and broadcasts `rebuilt` SSE frames, and the `client-hmr` plugin swaps one fiber per frame. The settled flip (`loader.await()` + an all-ACTIVE sweep) still switches the shell from the loading page to the real UI in one pass — settled means every entry is created and every fiber reached ACTIVE, with FAILED/PENDING fibers listed loud; there is no partial-availability mode (progressive rendering is deferred work).
|
||||
|
||||
The loading chain, end to end:
|
||||
|
||||
1. `GET /` → the shell boots, mounts `ctx.loader` (the loader mechanism is held statically by the shell — a loader cannot load itself; its code home is `packages/client/runtime/src/client/loader/`, imported through the `./loader` subpath so the shell bundle does not swallow the rest of the runtime package), seeds the require module table with the pure-library instances (react, react-dom, cordis, ui-slots, web-react, ui-primitives), and renders a plugin-independent loading page.
|
||||
2. `loader.start()` reads `__DSH_BOOT__`. Entries flagged `immediately` form the early-load group (connection, runtime, ui-theme, i18n): fetched in parallel, applied in intra-group `inject` topological order, and **the whole group must land before anything else loads**. Remaining plugins then load in inject order.
|
||||
3. Each bundle executes `window.DSHClientProxy.loadPlugin({ id, factory })`. The loader calls `factory(require)` — bundles are closure factories whose external dependencies arrive through the injected `require`, resolved against the module table (no globals, no import maps; an unresolvable specifier fails loud). The factory returns its module export surface (including the cordis `apply`); the loader runs `ctx.plugin(apply)`, then **registers that export surface into the module table under the package name**, so inject topology guarantees later plugins can `require` earlier ones. Plugin CSS is inlined in the bundle and injected as `<style data-plugin="<id>">` (CSS Modules hashing + ownership tag = isolation).
|
||||
4. `await loader.settled()` → the shell flips from the loading page to the real UI in one pass. A single failed plugin fails loud on the loading page; there is no partial-availability mode (progressive rendering is deferred work).
|
||||
|
||||
**The dual-instance ban**: a module-table package inlined into a plugin bundle would duplicate runtime identity (two React copies, two store registries — the root cause of an actual white-screen P0). The tsdown client preset enforces purity at build time: a bare-name import of a module-table package must resolve external (rewritten to its `/client` form where applicable), and any other workspace leak that is not an inline-safe wire/type layer fails the build (`packages/client/tsdown.client.ts`, pinned by `scripts/client-bundle-purity.spec.ts`).
|
||||
|
||||
Dev equals prod: plugins rebuild under `tsdown --watch`, refresh reloads the same chain; vite serves only the shell (`apps/web`). Type universes stay split at the aggregate level — `tsconfig.host.json` is the host program and `tsconfig.client.json` the client program, both referenced by the solution root `tsconfig.json` — because both sides merge cordis `Context` under the same keys (`sessions`, `loader`) with different services; client packages consume the wire vocabulary through pure type subpaths (`@deepseek-ai/dsh-session/types` and kin) so no host augmentation rides into the client program.
|
||||
Type universes stay split at the aggregate level — `tsconfig.host.json` is the host program and `tsconfig.client.json` the client program, both referenced by the solution root `tsconfig.json` — because both sides merge cordis `Context` under the same keys (`sessions`, `loader`) with different services; client packages consume the wire vocabulary through pure type subpaths (`@deepseek-ai/dsh-session/types` and kin) so no host augmentation rides into the client program.
|
||||
|
||||
## The slot system: how the page composes
|
||||
|
||||
|
||||
+10
-17
@@ -17,29 +17,22 @@ Status: implemented
|
||||
```
|
||||
┌─ Host ─────────────────────────┐ ┌─ Browser ─────────────────────────────────────────┐
|
||||
│ sessions/agents/SessionLog │ │ client cordis root ctx │
|
||||
│ apiproxy: RPC + mux/host 双流 │◀─▶│ ├ loader(壳静态持有,不能经自己装载) │
|
||||
│ webserver: │ │ ├ immediately 先行组: connection/runtime/ │
|
||||
│ ├ GET /plugins/<id>/client.js │ │ │ ui-theme/i18n(动态 bundle,并行先装) │
|
||||
│ └ GET / 注入 __DSH_BOOT__ │ │ ├ 后续组: layout/sidebar/conversation/trajectory │
|
||||
└────────────────────────────────┘ │ └ session scope ×N(观看驱动,惰性建) │
|
||||
│ apiproxy: RPC + mux/host 双流 │◀─▶│ ├ vendored Loader + ctx.modules(内核,壳静态持有)│
|
||||
│ webserver: │ │ ├ immediately entries: connection/runtime/ │
|
||||
│ ├ GET /plugins/<id>/client.js │ │ │ ui-theme/i18n(fetch bundle,boot 预拉) │
|
||||
│ └ GET / 注入 __DSH_BOOT__ 图 │ │ ├ lazy entries: layout/sidebar/ │
|
||||
│ │ │ │ conversation/trajectory(fetch bundle,按需) │
|
||||
└────────────────────────────────┘ │ ├ app-shell 伪行(壳内静态注册,同一治理) │
|
||||
│ └ session scope ×N(观看驱动,惰性建) │
|
||||
│ React: loading 页 → settled → 整 UI 一次成型 │
|
||||
└────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## client cordis 树与装载链
|
||||
|
||||
每个 UI 插件同时是一个 host 插件(双入口包):node 半边住在 host 的插件树里,由 host Loader 管辖其生命周期;浏览器半边是 tsdown 闭包 bundle,挂在包的 `exports["./client"]` 下。host webserver 从带 `dshClient` manifest 字段的已加载插件推导启动清单,注入页面为 `window.__DSH_BOOT__`——HTML 到手即知要拉什么,零额外往返。
|
||||
装载链——两类包(普通包 vs dshClient 插件)、模块系统/插件治理器之分、host 独家撰写的带修订号 entry 图之上的双层 boot、热重载——归 [client 插件装载 RFC](2026-07-23-client-plugin-loading-model.md) 所有。本篇赖以立足的事实:浏览器启动与 host 相同的 vendored `@cordisjs/plugin-loader`,由 client 模块系统(`ctx.modules`,`packages/client/modules`)填上其 `internal` seam;凡带产品行为的单元都是 host 独家撰写的 `__DSH_BOOT__` 图里的 entry——全部九个插件包(含基础设施)都携带 `dshClient` 声明、以 fetch 到达的 `./client` tsdown 闭包 bundle 供给,`immediately` 行的差别仅在 boot 第一层预取,而普通包(react 家族、cordis、尚未升格的库)保持打进壳、已播种、对图不可见;bundle 执行 `window.__ModuleLoader__.load({ id, factory })`,其 `require` 由 lazy CJS 模块表应答(种子词条 + 已登记工厂,首次 require 时物化并记忆化——跨插件值 import 是构建错误,协作走 cordis 服务);插件 CSS 内联在 bundle 里、物化时注入为 `<style data-plugin="<id>">`(CSS Modules 哈希 + 归属标记 = 隔离,重载时移除);热重载已在 dev 图落地——webserver 对自己供给的 bundle 做 stat 轮询并广播 `rebuilt` SSE 帧,`client-hmr` 插件每帧换掉一个 fiber。settled 翻转(`loader.await()` + 一次全 ACTIVE 扫描)依旧让壳从 loading 页一次切换到真 UI——settled 意味着每个 entry 已创建、每个 fiber 都到达 ACTIVE,FAILED/PENDING 的 fiber 被大声列出;不存在部分可用模式(渐进渲染为后置工作)。
|
||||
|
||||
装载链全程:
|
||||
|
||||
1. `GET /` → 壳启动,挂 `ctx.loader`(loader 机件由壳静态持有——装载器不能经自己装载;其代码家在 `packages/client/runtime/src/client/loader/`,壳经 `./loader` 子路径 import,避免壳 bundle 吞掉 runtime 包其余部分),把纯库实体(react、react-dom、cordis、ui-slots、web-react、ui-primitives)播种进 require 模块表,渲染一张不依赖任何插件的 loading 页。
|
||||
2. `loader.start()` 读取 `__DSH_BOOT__`。带 `immediately` 标记的条目构成先行装载组(connection、runtime、ui-theme、i18n):并行拉取、按组内 `inject` 拓扑序 apply,**全组就位后才开始装载其余插件**。其余插件随后按 inject 序装载。
|
||||
3. 每个 bundle 执行 `window.DSHClientProxy.loadPlugin({ id, factory })`。loader 调 `factory(require)`——bundle 是闭包工厂,external 依赖经注入的 `require` 到达,从模块表解析(无全局变量、无 import map;解析不到的标识符即刻大声失败)。factory 返回其模块导出面(含 cordis `apply`);loader 执行 `ctx.plugin(apply)`,随后**以包名把该导出面登记进模块表**——inject 拓扑保证后装插件可 `require` 先装插件。插件 CSS 内联在 bundle 里,注入为 `<style data-plugin="<id>">`(CSS Modules 哈希 + 归属标记 = 隔离)。
|
||||
4. `await loader.settled()` → 壳从 loading 页一次切换到真 UI。单插件装载失败在 loading 页大声报错;不存在部分可用模式(渐进渲染为后置工作)。
|
||||
|
||||
**双实例禁令**:模块表包若被内联进插件 bundle,会复制运行时身份(两份 React、两套 store 注册表——一次真实白屏 P0 的根因)。tsdown client 预设在构建期把守纯度:模块表包的裸名 import 必须解析为 external(适用时改写为其 `/client` 形态),其余任何非 inline 安全 wire/类型层的 workspace 泄漏都令构建大声失败(`packages/client/tsdown.client.ts`,由 `scripts/client-bundle-purity.spec.ts` 钉住)。
|
||||
|
||||
dev 与 prod 同链:插件在 `tsdown --watch` 下重编译,刷新即重走同一条链;vite 只管壳(`apps/web`)。类型宇宙在聚合层拆分——`tsconfig.host.json` 是 host program、`tsconfig.client.json` 是 client program,二者由 solution 根 `tsconfig.json` 引用,因为两侧都在相同键(`sessions`、`loader`)上对 cordis `Context` 做声明合并且服务不同;client 包经纯类型子路径(`@deepseek-ai/dsh-session/types` 等)消费协议词汇,host 侧的声明合并不会搭车进入 client program。
|
||||
类型宇宙在聚合层拆分——`tsconfig.host.json` 是 host program、`tsconfig.client.json` 是 client program,二者由 solution 根 `tsconfig.json` 引用,因为两侧都在相同键(`sessions`、`loader`)上对 cordis `Context` 做声明合并且服务不同;client 包经纯类型子路径(`@deepseek-ai/dsh-session/types` 等)消费协议词汇,host 侧的声明合并不会搭车进入 client program。
|
||||
|
||||
## slot 体系:页面怎么拼
|
||||
|
||||
@@ -112,7 +105,7 @@ src/client/
|
||||
|
||||
## 怎么开发
|
||||
|
||||
- **新 UI 功能** = 新插件包:package.json 声明 `dshClient`(+ `inject` 拓扑),浏览器半边写在 `src/client/`(apply 挂服务/建 store、注册 slot),无 host 逻辑时 node 半边保持空 apply,用共享预设构建。把插件加进 host 配置;清单与装载随之自动跟上。
|
||||
- **新 UI 功能** = 新插件包:package.json 声明 `dshClient`(+ `inject` 拓扑),浏览器半边写在 `src/client/`(apply 挂服务/建 store、注册 slot),无 host 逻辑时 node 半边保持空 apply,用共享预设构建。把插件加进 host 配置;manifest 与装载随之自动跟上。
|
||||
- **新 slot**:见 [slot 体系标准 RFC](2026-07-22-slot-type-chain-implementation.md)——契约合并进 `SlotMap`,在父 entry 的 `children` 里声明,经自动注入的 `renderSlot` prop 渲染。永不全局导出组件。
|
||||
- **消费新帧类型**:带 sessionId → Session 分发 switch 加一个分支;host 级 → Manager 路由表;UI 需要时给 `ConversationSnapshot` 加字段并守住引用纪律。
|
||||
- **状态住哪**:业务数据(事件、流式、待答)→ 永远对象层;父知道的 → renderSlot 现场的 owner props;单组件私有(滚动、搜索词、展开集)→ 组件状态;跨 entry 共享或跨重挂载存活(选中、草稿、面板宽)→ entry 声明的 store([slot 体系标准](2026-07-22-slot-type-chain-implementation.md))。
|
||||
|
||||
+6
@@ -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-23-client-plugin-loading-model.md: 58651fd258a6b2929c58bb6f93b44adb6e8e1818
|
||||
2026-07-23-client-plugin-loading-model.zh.md: f60b06c7bfaa9c70170082ac4384ba2bd899676e
|
||||
@@ -0,0 +1,132 @@
|
||||
# Agent Note: Client plugin loading — plain packages, dshClient plugins, and the two-phase boot
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-23-client-plugin-loading-model.zh.md)
|
||||
|
||||
> Scope: the browser-side plugin loading machinery — what is a plugin, how code arrives, and how hot reload rides on that model. This note owns the loading chain; the [web client architecture RFC](2026-07-19-gui-web-client-architecture.md) defers to it for loading and keeps owning slots, the data object layer, and the React face.
|
||||
|
||||
## Problem
|
||||
|
||||
On the host, cordis plugin loading stands on Node's module machinery — the require cache and the internal ESM loader own module identity and bytes. The vendored `@cordisjs/plugin-loader` implements plugin governance and hot reload on top of that substrate, and the two meet at one seam: `Loader.internal`.
|
||||
|
||||
The browser client runs the same cordis plugin mechanism, so it needs the same substrate underneath — and the browser has no Node module system.
|
||||
|
||||
Conventional frontend engineering digests all dependencies at build time: one bundle, externals resolved by the bundler, nothing left to manage at runtime. Runtime module management on top of that is the unusual requirement here. The client therefore splits into two layers: the upper layer is cordis plugin loading through the same vendored Loader, and the lower layer is module-granular dependency management — `dsh-client-modules`.
|
||||
|
||||
The lower layer supplies four capabilities: externals (the platform list), remote arrival (bundle fetch plus lazy factory registration), versioning (content-hash revs), and hot update (invalidate/prefetch).
|
||||
|
||||
On top of that, client and host plugins register and load consistently: a package declares `dshClient` once, the host scans the declaration into the boot graph, and the same Loader semantics govern entries on both sides.
|
||||
|
||||
The first-generation client loader (`createClientLoader`) hand-wrote both layers in one function. The fusion left no unload/reload path (loads were one-shot, style tags never removed), hand-copied dependency lists that had already drifted across three files, and a module-table backdoor for cross-plugin imports that duplicated cordis's service mechanism while making load order a correctness constraint. The structure below replaced it.
|
||||
|
||||
## Decision
|
||||
|
||||
### Two package kinds; `dshClient` means plugin, period
|
||||
|
||||
What makes a package a plugin? One rule: **a package is a plugin package once its consumption is cordis dependency injection; until then it is a plain package.** How code reaches the page is not part of the taxonomy — arrival follows from the kind instead of defining it.
|
||||
|
||||
- **Plain packages** are the absolute base the module system itself needs, plus libraries not yet converted to DI: the react family, cordis, `@deepseek-ai/dsh-client-modules` (the module system itself — it can never be a plugin, because modules precede all modules), the web shell kernel, and — for now — ui-slots, web-react, ui-primitives. Plain packages are shell-bundled, seeded into the module table, and invisible to the host graph.
|
||||
- **Plugin packages** are everything else. Each one carries a `dshClient` manifest declaration (`{ platform, inject, immediately? }`) and one uniform shape: the shared tsdown preset emits `lib/client.js`, and `exports["./client"]` points at that bundle. Each is a governed entry of the host-authored graph. Nine exist today: connection, runtime, ui-theme, i18n, hmr (dev graphs only), ui-layout, ui-sidebar, ui-conversation, ui-trajectory.
|
||||
|
||||
The manifest owns the package's loading contract: its `inject` dependency edges, plus the optional `immediately` prefetch mark (absent means lazy). The composing app owns only the roster and the `--dev` switch.
|
||||
|
||||
To add a plugin package: declare `dshClient`, emit the `./client` bundle through the shared preset, add the name to the composing app's roster. Nothing else changes hands.
|
||||
|
||||
When does a plain package become a plugin? The upgrade law, recorded so the migration path stays honest: **a plain package becomes a plugin package when its consumers switch to cordis DI, not before.** Three promotions are queued: ui-slots (will receive the slots machinery now living in runtime — SlotsService, the renderer seam, the root slot), web-react (will take the renderer install into its own `apply`), and ui-primitives (once components are served through slots/services). Until then they stay plain, and their symbol exports stay ordinary static imports.
|
||||
|
||||
Four edge rules govern imports across the two kinds. None of them depends on any per-package mark:
|
||||
|
||||
- **Plugin ↔ plugin value imports are a build error.** This holds regardless of either side's `immediately` declaration — the rule must not depend on a mark someone can flip. Cooperation goes through cordis inject/services. `import type` is exempt; the type chain is untouched. This rule is why `scopeOf` is a `SessionsService` method and why `transportError` lives in `dsh-host-apiproxy`'s wire layer (its `RpcResult` home, inline-safe).
|
||||
- **Plugin → plain package value imports are externals**, judged against the platform list. That list is one constant in the shell (`platform.ts`: react family, cordis, ui-slots, web-react, ui-primitives), imported by both the tsdown preset (for the external judgement) and `seed.ts` (for the table warm-up). One constant, two consumers — the hand-sync drift class stays dead.
|
||||
- **The purity gate covers all nine plugin packages.** Its three branches: platform imports become externals; INLINE_SAFE wire layers are inlined; any other workspace leak is a build error. The uniform bundle shape is what makes this coverage total — every plugin builds through the same preset, so no package can sit outside the gate.
|
||||
- **The shell is self-sufficient.** The kernel (boot + loading page) value-imports no plugin package; its status stores are hand-rolled. The fail-loud presentation must not depend on the system whose failure it reports.
|
||||
|
||||
### One module system, one plugin governor
|
||||
|
||||
The browser mirrors the host's division of labor. `dsh-client-modules` (`ClientModuleSystem`) takes the module-system seat that Node's internal ESM loader holds host-side; the same vendored `@cordisjs/plugin-loader` keeps the governance seat on both sides. The line between them in one sentence: **the module system owns module identity and bytes — how code arrives, registers, and becomes an export surface; the Loader owns plugin lifecycle — when a plugin mounts, what it waits for, and how it is torn down.**
|
||||
|
||||
`ClientModuleSystem` is a lazy CJS table. Executing a bundle only **registers** its factory — the bundle calls `window.__ModuleLoader__.load({ id, factory })` and nothing else happens. Every module body side effect, CSS injection included, lives inside the factory closure and runs at materialization: the first `require`/import of that id, memoized after that. A factory that requires a registered-but-unmaterialized sibling materializes it recursively, so no sort order exists anywhere. When asked to import an id, the table resolves through a fixed branch order: seed word → memoized record → static registration (shell-own modules, e.g. app-shell) → registered factory → graph-row fetch + execute → loud throw. That final throw is the runtime mirror of the build-time purity gate. The system also keeps per-module bookkeeping — owned `<style data-plugin>` tag ids, observed require edges — and exposes the two verbs HMR needs: `prefetch(id)` (fetch + execute, registration only; concurrent calls share one in-flight task) and `invalidate(id)` (drop factory, record, and consumed text so the next arrival refetches).
|
||||
|
||||
The vendored Loader consumes the module system through its `internal` seam — the only call site is `tree.import` — and owns everything entry-shaped: entry creation, fiber activation through cordis service waiting (PENDING until injected services exist, cascading when a service is provided), update/refresh, teardown. The governance code is byte-identical to the host side, per vendor policy. Browserization is compile-time mapping in the shell's vite config: a `node:module` stub alias plus `process.*` defines make `ModuleLoader.fromInternal()` return undefined — exactly the empty slot the shell fills. The module system mounts as `ctx.modules`.
|
||||
|
||||
### The loading flow, end to end
|
||||
|
||||
What happens between `dsh web` starting and the UI appearing? Three stages: the host composes and serves a graph, the shell prefetches, then cordis orchestrates.
|
||||
|
||||
**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/<id>/client.js?rev=…`. The graph types are a wire contract dual-held on both sides, because the webserver keeps zero workspace dependencies.
|
||||
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
**Phase two — the plugin face.**
|
||||
|
||||
1. The kernel mounts the vendored Loader and injects the module system as `internal` before any entry exists. Ordering matters: `tree.import`'s bare-import fallback must never run in a browser.
|
||||
2. It creates one entry per graph row, plus the app-shell pseudo-row. The assembly entry is shell-own code the kernel appends itself — registered static with the module system, never part of the host graph — so it rides the same entry lifecycle and status coverage as everything else.
|
||||
3. Creation order carries no semantics; fibers activate through service waiting.
|
||||
4. `settled` = every entry created + `loader.await()` quiescent + an all-ACTIVE sweep. The sweep lists each import-failed, FAILED, or PENDING fiber with its missing services. It exists because cordis inject waits have no timeout — the sweep is the fail-loud floor.
|
||||
5. The loading page's boot status is a projection of real fiber states via `internal/status`. The settled flip switches to the real UI in one pass.
|
||||
|
||||
### 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.
|
||||
|
||||
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.
|
||||
|
||||
On the browser side, the driver reloads one plugin per frame, serialized:
|
||||
|
||||
1. `invalidate` — drop the stale factory and record. A live factory would make the next step a no-op.
|
||||
2. `prefetch` — fetch + execute + register the fresh factory, while the old fiber still serves.
|
||||
3. `registry.delete` — before touching the fiber. A bare fiber dispose trips the vendored Loader's self-dispose branch, which would disable the entry permanently.
|
||||
4. Drain the old fiber's disposers.
|
||||
5. Remove owned `<style data-plugin>` tags.
|
||||
6. `entry.refresh()` — re-imports, materializing the fresh factory. CSS re-injects here, under the same stable tag ids.
|
||||
7. `fiber.await()` — rethrows loud.
|
||||
|
||||
All nine plugins share this one semantics; an `immediately` row reloads exactly like a lazy one. Dependency cascade costs zero client code: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber re-loads every dependent through cordis itself. Reloading connection or runtime cascades the whole UI — correct, if heavy.
|
||||
|
||||
The support boundary, stated honestly. Reload is coarse by design: fresh fiber, fresh components, React state lost, data layer untouched — react-refresh-grade state preservation conflicts with "re-executing the bundle re-runs the factory" and is deliberately out. Plain packages (react family, shell kernel, not-yet-promoted libraries) are not entries: changing them means a shell rebuild and a full page reload. No rollback in v1: an import failure leaves the entry fiberless and the next rebuilt frame retries from scratch; an apply failure leaves a FAILED fiber for the status projection; both log loudly. Self-reload works — the in-flight reload finishes in the old bundle's closure and the new apply opens a fresh SSE channel — but frames arriving in the gap are lost, and the next rebuild renotifies. One known dev-only race: a rebuilt frame overlapping a still-in-flight boot arrival shares that arrival's task and may materialize the pre-rebuild bytes; the next frame self-heals.
|
||||
|
||||
## Package inventory (today → long term)
|
||||
|
||||
| Package | Role | Today | Long term |
|
||||
|---|---|---|---|
|
||||
| react family / cordis | platform singletons | shell-bundled, seeded | plain forever (absolute base) |
|
||||
| vendored `@cordisjs/plugin-loader` | entry governance (same code both sides) | compile-time browserization, kernel-mounted | untouched (vendor policy) |
|
||||
| `dsh-client-modules` | the client module system | lazy CJS table; two-phase boot | plain forever (modules precede modules) |
|
||||
| `dsh-client-web` | shell kernel + AppRoot + app-shell assembly | self-sufficient (hand-rolled status stores, no plugin value imports) | keeps shrinking |
|
||||
| `dsh-client-ui-slots` | slot registry core | plain, seeded | promote to plugin; receive runtime's slots machinery |
|
||||
| `dsh-client-web-react` | ctx↔React glue | plain, seeded | promote to plugin; renderer install moves into its apply |
|
||||
| `dsh-client-ui-primitives` | base components | plain, seeded | promote to plugin (components via slots/services) |
|
||||
| `dsh-client-connection` | wire layer | plugin (dshClient + bundle), declares `immediately` | transport swap (Electron IPC carrier) |
|
||||
| `dsh-client-runtime` | session object layer + slots service + store engine | plugin, declares `immediately` | keeps shrinking toward a pure session object layer |
|
||||
| `dsh-client-ui-theme` | theme tokens/service | plugin, declares `immediately`, plus the `./styles/*` source channel | Theme Registry (separate ruling) |
|
||||
| `dsh-client-i18n` | I18nService | plugin, declares `immediately` | per-deployment locale composition |
|
||||
| `dsh-client-hmr` | hot reload driver | plugin, declares `immediately`; dev graphs only | rollback; reconnect handshake |
|
||||
| ui-layout / ui-sidebar / ui-conversation / ui-trajectory | UI features | plugins, on-demand | conversation domain split; trajectory real implementation |
|
||||
|
||||
## Consequences
|
||||
|
||||
One governance implementation runs on both sides of the wire; the browser-specific surface is one module system plus one reload plugin. Plugin packages have one shape, so the purity gate covers them all. Dependency edges and the boot tier live with their owners — the manifests — while the composing app holds only the roster and the `--dev` switch. The drift classes stay structurally closed: share-list hand-sync, load-order coupling, cross-plugin imports, roster/tier double bookkeeping.
|
||||
|
||||
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.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
| Rejected | One-line reason |
|
||||
|---|---|
|
||||
| Two-axis taxonomy (entry × arrival) with non-dshClient infrastructure packages | Erased manifest dependency edges (inject leaked to the composer), split the plugin shape in two, blinded the purity gate to half the plugins |
|
||||
| Keep evolving the hand-written loader into a governor | Re-implements entry/fiber lifecycle the vendored Loader owns; HMR would have no shared skeleton with the host side |
|
||||
| Reuse `@cordisjs/plugin-hmr` in the browser | ~80% solves problems the browser doesn't have (fs watching, deep graph coloring, Node's dual caches); the reload skeleton is copied as a shape |
|
||||
| Module federation | Independently built remote bundles are exactly the form vite federation does not support |
|
||||
| Import maps | Ruled out earlier; the DI require table is the terminal mechanism |
|
||||
| Full ctx-ification now (react and libraries via services, no module table) | The module-axis extreme; parked — the upgrade law walks there one package at a time instead |
|
||||
| Eager instantiation with a frozen table | Requires arrival-time ordering; lazy CJS registration makes recursive `require` self-ordering and matches the naive-puller phase split |
|
||||
| Builder-push rebuild channel (`POST /plugins/rebuilt` from the orchestrator's `onSuccess`) | Couples reload to one blessed builder process and a second wire protocol; the webserver already holds every bundle path, and stat polling covers the torn-write race (re-hash on every stat change) that once justified pushing |
|
||||
@@ -0,0 +1,132 @@
|
||||
# Agent Note: client 插件装载——普通包、dshClient 插件与双层 boot
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-23-client-plugin-loading-model.md) | 中文
|
||||
|
||||
> 范围:浏览器侧的插件装载机件——什么是插件、代码怎么到达、热重载如何搭在这套模型上。装载链归本篇所有;[Web 客户端架构 RFC](2026-07-19-gui-web-client-architecture.md) 在装载问题上以本篇为准,继续拥有 slot、数据对象层与 React 面。
|
||||
|
||||
## Problem
|
||||
|
||||
host 侧,cordis 插件装载站在 Node 的模块机制之上——require cache 与内部 ESM loader 拥有模块身份与字节。vendored `@cordisjs/plugin-loader` 在这层基座之上实现插件治理与热重载,二者在唯一一道 seam 相接:`Loader.internal`。
|
||||
|
||||
浏览器客户端跑同一套 cordis 插件机制,因此底下需要同样的基座——而浏览器没有 Node 模块系统。
|
||||
|
||||
常规前端工程在构建期消化全部依赖:单一 bundle,external 由打包器解决,运行时无物可管。在此之上再做运行时模块管理,正是这里的特殊需求。client 因此拆成两层:上层是经同一份 vendored Loader 的 cordis 插件装载,下层是模块粒度的依赖管理——`dsh-client-modules`。
|
||||
|
||||
下层供给四项能力:external(平台清单)、远程到达(bundle 拉取加惰性工厂登记)、版本化(内容哈希 rev)、热更新(invalidate/prefetch)。
|
||||
|
||||
在此之上,client 与 host 插件以一致的方式注册与装载:包声明一次 `dshClient`,host 把声明扫描进 boot 图,同一套 Loader 语义在两侧治理 entry。
|
||||
|
||||
第一代 client loader(`createClientLoader`)把这两层手写进了同一个函数。这一融合留下的是:没有卸载/重载路径(装载一次性,style 标签从不移除)、在三个文件间人肉抄写且早已漂移的依赖清单、一条供跨插件 import 走的模块表后门——既复制了 cordis 的服务机制,又把装载顺序变成正确性约束。下文的结构取代了它。
|
||||
|
||||
## Decision
|
||||
|
||||
### 两类包;`dshClient` 即插件,别无他义
|
||||
|
||||
什么让一个包成为插件?只有一条规则:**一个包的消费方式一旦是 cordis 依赖注入,它就是插件包;在此之前它是普通包。**代码怎么到达页面不属于分类体系——到达方式由包的类别推得,而不是反过来定义类别。
|
||||
|
||||
- **普通包**是模块系统自身所需的绝对基座,加上尚未转成 DI 的库:react 家族、cordis、`@deepseek-ai/dsh-client-modules`(模块系统本身——它永远不可能是插件,因为模块先于一切模块)、web 壳内核,以及——暂时——ui-slots、web-react、ui-primitives。普通包打进壳 bundle、播种进模块表、对 host 图不可见。
|
||||
- **插件包**是其余一切。每个都携带 `dshClient` manifest(元数据清单)声明(`{ platform, inject, immediately? }`)和同一种统一形态:共享 tsdown 预设产出 `lib/client.js`,`exports["./client"]` 指向该 bundle。每个都是 host 独家撰写的图里受治理的 entry。现有九个:connection、runtime、ui-theme、i18n、hmr(仅进 dev 图)、ui-layout、ui-sidebar、ui-conversation、ui-trajectory。
|
||||
|
||||
manifest 拥有包的装载契约:它的 `inject` 依赖边,加可选的 `immediately` 预取标记(缺省即 lazy)。负责组合的 app 只拥有名册与 `--dev` 开关。
|
||||
|
||||
新增一个插件包:声明 `dshClient`,经共享预设产出 `./client` bundle,把包名加进负责组合的 app 的名册。除此之外无需任何交接。
|
||||
|
||||
普通包何时升格为插件?升级法则,记录在案让迁移路径保持诚实:**普通包在其消费方改用 cordis DI 之时升格为插件包,绝不提前。**三项升格在排队:ui-slots(将接收现居 runtime 的 slots 机件——SlotsService、渲染器 seam、root slot)、web-react(将把渲染器安装收进自己的 `apply`)、ui-primitives(组件经 slot/服务供给之时)。在那之前它们保持普通包身份,符号导出保持普通的静态 import。
|
||||
|
||||
四条边规则治理横跨两类包的 import。没有一条依赖任何单包标记:
|
||||
|
||||
- **插件 ↔ 插件的值 import 是构建错误。**与两侧的 `immediately` 声明无关——规则不得依赖一个人人可翻转的标记。协作走 cordis inject/服务。`import type` 豁免;类型链分毫未动。这条规则正是 `scopeOf` 是 `SessionsService` 方法、`transportError` 住在 `dsh-host-apiproxy` wire 层(它的 `RpcResult` 老家,内联安全)的原因。
|
||||
- **插件 → 普通包的值 import 外置为 external**,按平台清单判定。清单是壳里的一个常量(`platform.ts`:react 家族、cordis、ui-slots、web-react、ui-primitives),tsdown 预设(external 判定)与 `seed.ts`(模块表预热)都 import 它。一个常量、两个消费方——人肉同步这一漂移缺陷类死透。
|
||||
- **纯度门禁覆盖全部九个插件包。**它的三条分支:平台 import 外置为 external;INLINE_SAFE wire 层内联;其余任何 workspace 泄漏即构建错误。正是统一的 bundle 形态让这一覆盖不留死角——每个插件都经同一预设构建,没有包能坐在门禁之外。
|
||||
- **壳自足。**内核(boot + loading 页)对任何插件包零值 import;其状态 store 为手写。大声失败的呈现不得依赖它所报告失败的那个系统。
|
||||
|
||||
### 一套模块系统,一个插件治理器
|
||||
|
||||
浏览器复刻 host 侧的分工。`dsh-client-modules`(`ClientModuleSystem`)坐上 host 侧由 Node 内部 ESM loader 占据的模块系统席位;同一份 vendored `@cordisjs/plugin-loader` 在两侧都坐治理席。二者的分界线一句话说尽:**模块系统拥有模块身份与字节——代码怎么到达、怎么登记、怎么变成导出面;Loader 拥有插件生命周期——插件何时挂载、等待什么、如何拆除。**
|
||||
|
||||
`ClientModuleSystem` 是一张 lazy CJS 表。执行 bundle 只**登记**其工厂——bundle 调用 `window.__ModuleLoader__.load({ id, factory })`,此外什么都不发生。模块体的一切副作用(包括 CSS 注入)都住在工厂闭包里,在物化时运行:物化即该 id 的首次 `require`/import,此后记忆化。工厂若 require 一个已登记未物化的同伴,就递归物化它,因此任何地方都不存在排序。被要求 import 一个 id 时,表按固定分支顺序解析:种子词条 → 记忆化的记录 → 静态登记(壳自有模块,如 app-shell)→ 已登记的工厂 → 图行 fetch + 执行 → 大声抛错。最后这一抛是构建期纯度门禁在运行期的镜像。系统还保管逐模块的簿记——名下 `<style data-plugin>` 标签 id、观测到的 require 边——并暴露 HMR(热模块替换)需要的两个动词:`prefetch(id)`(fetch + 执行、只登记;并发调用共享同一在途任务)与 `invalidate(id)`(丢弃工厂、记录与已消费文本,下次到达即重新拉取)。
|
||||
|
||||
vendored Loader 经其 `internal` seam 消费模块系统——唯一调用点是 `tree.import`——并拥有一切 entry 形状的事务:entry 创建、fiber 经 cordis 服务等待的激活(注入的服务未就位即保持 PENDING,服务 provide 时级联激活)、update/refresh、拆除。治理代码按 vendor 政策与 host 侧逐字节相同。浏览器化是壳 vite 配置里的编译期映射:一个 `node:module` stub 别名加若干 `process.*` define,使 `ModuleLoader.fromInternal()` 返回 undefined——这正是留给壳来填的空槽。模块系统挂载为 `ctx.modules`。
|
||||
|
||||
### 装载流程,端到端
|
||||
|
||||
从 `dsh web` 启动到 UI 出现之间发生了什么?三个阶段:host 组合并供给一张图,壳预取,然后 cordis 编排。
|
||||
|
||||
**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/<id>/client.js?rev=…`。图的类型是两侧各持一份的 wire 契约,因为 webserver 保持零 workspace 依赖。
|
||||
|
||||
为什么名册是手写清单而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个 dshClient 包存在于仓库里,不代表这次部署要挂载它,扫描发现无从替人做这个决定。名册住在 `apps/cli/web.ts` 而非 cordis.yml,只是因为 `dsh web` 的 host 还是一个手工装配的 `bootHost`,没有 Loader 配置树。
|
||||
|
||||
**第一层——模块面。**壳在图之上建起模块系统,然后并行预取每个 `immediately` 行。预取即 fetch + 执行,只登记工厂。单行预取失败在这里被吞下:第二层 import 时会重试 fetch 并拥有那次大声失败,因此一个坏行藏不住其他行。`immediately` 是预取标记——不是屏障,不是身份。包声明它,注册表把它带进图行。基础设施插件(connection、runtime、ui-theme、i18n,外加 hmr)声明它;UI 插件则径直按需到达。
|
||||
|
||||
**第二层——插件面。**
|
||||
|
||||
1. 内核挂载 vendored Loader,在任何 entry 存在之前就把模块系统注入为 `internal`。顺序有讲究:`tree.import` 的裸 import 兜底分支在浏览器里绝不能跑到。
|
||||
2. 它为图中每一行创建 entry,外加 app-shell 伪行。装配 entry 是内核自己追加的壳自有代码——向模块系统静态登记,绝不进 host 图——因此与其余一切共乘同一套 entry 生命周期与状态覆盖。
|
||||
3. 创建顺序不携带任何语义;fiber 经服务等待激活。
|
||||
4. `settled` = 每个 entry 已创建 + `loader.await()` 停稳 + 一次全 ACTIVE 扫描。扫描列出每个 import 失败、FAILED 或 PENDING 的 fiber 及其缺失的服务。它存在的理由:cordis 的 inject 等待没有超时——这次扫描就是大声失败的兜底线。
|
||||
5. loading 页的启动状态是经 `internal/status` 对真实 fiber 状态的投影。settled 翻转即一次性切换到真实 UI。
|
||||
|
||||
### 热重载:一个驱动插件,自行监视的 bundle
|
||||
|
||||
热重载是否启用是一项组合决策:dev 图包含 `client-hmr` 行(一个常规的插件包)并开启 bundle 监视;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。
|
||||
|
||||
浏览器侧,驱动插件每帧重载一个插件,串行执行:
|
||||
|
||||
1. `invalidate`——丢弃陈旧的工厂与记录。工厂还活着会让下一步变成 no-op。
|
||||
2. `prefetch`——fetch + 执行 + 登记新工厂,旧 fiber 此刻仍在服役。
|
||||
3. `registry.delete`——先于任何 fiber 操作。裸做 fiber dispose 会触发 vendored Loader 的自 dispose 分支,把 entry 永久停用。
|
||||
4. 排空旧 fiber 的各 disposer。
|
||||
5. 移除名下的 `<style data-plugin>` 标签。
|
||||
6. `entry.refresh()`——重新 import,物化新工厂。CSS 在这里重新注入,沿用同一批稳定标签 id。
|
||||
7. `fiber.await()`——让失败大声重抛。
|
||||
|
||||
九个插件共享这同一套语义;`immediately` 行的重载与 lazy 行分毫不差。依赖级联不花一行 client 代码:fiber 的激活纪元串接着它各服务提供方的 uid,因此换掉提供方的 fiber,每个依赖方都会经 cordis 本身重新装载。重载 connection 或 runtime 会级联整个 UI——正确,虽然重。
|
||||
|
||||
支持边界,如实陈述。重载粒度刻意做粗:全新 fiber、全新组件、React 状态丢失、数据层不动——react-refresh 级的状态保留与「重执行 bundle 即重跑工厂」相冲突,属刻意不做。普通包(react 家族、壳内核、尚未升格的库)不是 entry:改它们意味着壳重建加整页刷新。v1 不做回滚:import 失败让 entry 失去 fiber,下一个 rebuilt 帧从头重试;apply 失败留下 FAILED fiber 交给状态投影;两者都大声记录。自我重载可行——在途的重载在旧 bundle 的闭包里跑完,新的 apply 再开一条新 SSE 通道——但空窗期到达的帧会丢失,下次重建会再次通知。一处已知的仅限 dev 竞态:rebuilt 帧与仍在途的 boot 到达重叠时共享那次到达的任务,可能物化重建前的字节;下一帧自愈。
|
||||
|
||||
## 包盘点(现状 → 长期)
|
||||
|
||||
| 包 | 角色 | 现状 | 长期 |
|
||||
|---|---|---|---|
|
||||
| react 家族 / cordis | 平台单例 | 打进壳,已播种 | 永为普通包(绝对基座) |
|
||||
| vendored `@cordisjs/plugin-loader` | entry 治理(两侧同一份代码) | 编译期浏览器化,内核挂载 | 不动(vendor 政策) |
|
||||
| `dsh-client-modules` | client 模块系统 | lazy CJS 模块表;双层 boot | 永为普通包(模块先于模块) |
|
||||
| `dsh-client-web` | 壳内核 + AppRoot + app-shell 装配 | 自足(手写状态 store,零插件值 import) | 持续缩小 |
|
||||
| `dsh-client-ui-slots` | slot 注册表核心 | 普通包,已播种 | 升格为插件;接收 runtime 的 slots 机件 |
|
||||
| `dsh-client-web-react` | ctx↔React 胶水 | 普通包,已播种 | 升格为插件;渲染器安装移入其 apply |
|
||||
| `dsh-client-ui-primitives` | 基础组件 | 普通包,已播种 | 升格为插件(组件经 slot/服务供给) |
|
||||
| `dsh-client-connection` | wire 层 | 插件(dshClient + bundle),声明 `immediately` | 传输替换(Electron IPC 载体) |
|
||||
| `dsh-client-runtime` | 会话对象层 + slots 服务 + store 引擎 | 插件,声明 `immediately` | 持续缩向纯会话对象层 |
|
||||
| `dsh-client-ui-theme` | 主题 token/服务 | 插件,声明 `immediately`,外加 `./styles/*` 源码通道 | Theme Registry(另行裁定) |
|
||||
| `dsh-client-i18n` | I18nService | 插件,声明 `immediately` | 按部署组合语言包 |
|
||||
| `dsh-client-hmr` | 热重载驱动 | 插件,声明 `immediately`;仅进 dev 图 | 回滚;重连握手 |
|
||||
| ui-layout / ui-sidebar / ui-conversation / ui-trajectory | UI 功能 | 插件,按需到达 | conversation 域拆分;trajectory 真实现 |
|
||||
|
||||
## Consequences
|
||||
|
||||
wire 两侧跑着同一份治理实现;浏览器特有的表面只是一套模块系统加一个重载插件。插件包只有一种形态,纯度门禁因此覆盖全部插件。依赖边与启动档位都与其所有者——manifest——同住,负责组合的 app 只握名册与 `--dev` 开关。各漂移缺陷类被结构性关死:共享清单人肉同步、装载顺序耦合、跨插件 import、名册/档位双重记账。
|
||||
|
||||
接受的代价: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。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
| Rejected | One-line reason |
|
||||
|---|---|
|
||||
| 两轴分类体系(entry × 到达),基础设施包不带 dshClient | 抹掉了 manifest 依赖边(inject 泄漏给组合方)、把插件形态拆成两种、让纯度门禁对一半插件失明 |
|
||||
| 继续把手写 loader 演化成治理器 | 重新实现 vendored Loader 已拥有的 entry/fiber 生命周期;HMR 将与 host 侧毫无共享骨架 |
|
||||
| 在浏览器复用 `@cordisjs/plugin-hmr` | 约 80% 在解决浏览器没有的问题(fs 监听、深度图着色、Node 的双缓存);只按形状抄用其重载骨架 |
|
||||
| 模块联邦(module federation) | 独立构建的远端 bundle 恰是 vite 联邦不支持的形态 |
|
||||
| import map | 早已排除;DI require 表是终局机制 |
|
||||
| 现在就彻底 ctx 化(react 与库全走服务,不设模块表) | 模块轴上的极端形态;搁置——升级法则改为一次一包走向它 |
|
||||
| 冻结表 + 到达即实例化 | 要求按到达时刻排序;lazy CJS 登记让递归 `require` 自行定序,且与朴素拉取器的分层相合 |
|
||||
| 构建器推送重建通道(编排器在 `onSuccess` 里 POST `/plugins/rebuilt`) | 把重载耦合到一个钦定的构建器进程和第二套 wire 协议;webserver 本就握有每个 bundle 路径,stat 轮询(每次 stat 变化即重哈希)已兜住当年为推送辩护的撕裂写竞态 |
|
||||
@@ -14,6 +14,16 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-hmr": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-i18n": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-question": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
|
||||
"@deepseek-ai/dsh-frontend": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-runtime": "workspace:^",
|
||||
|
||||
+54
-3
@@ -13,12 +13,41 @@ import { createHostWebPluginRegistry, startWebServer } from '@deepseek-ai/dsh-ho
|
||||
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
|
||||
|
||||
export async function runWeb(argv: string[]): Promise<void> {
|
||||
const { values } = parseArgs({
|
||||
args: argv,
|
||||
options: {
|
||||
host: { type: 'string', default: LOOPBACK_HOST },
|
||||
port: { type: 'string', default: '3080' },
|
||||
dev: { type: 'boolean', default: false },
|
||||
},
|
||||
allowPositionals: false,
|
||||
})
|
||||
@@ -44,15 +73,37 @@ export async function runWeb(argv: string[]): Promise<void> {
|
||||
},
|
||||
})
|
||||
|
||||
// Web UI plugin chain: in-memory Loader tree over the eight UI packages,
|
||||
// then the registry that feeds __DSH_BOOT__ and /plugins/<id>/client.js.
|
||||
const mounted = await mountWebPlugins(host.ctx)
|
||||
// Client plugin chain: in-memory Loader tree over the composed roster, then
|
||||
// the registry that feeds the __DSH_BOOT__ entry graph and
|
||||
// /plugins/<id>/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)
|
||||
|
||||
+51
-7
@@ -8,12 +8,56 @@
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{ "path": "../../vendor/cordis" },
|
||||
{ "path": "../../packages/host/apiproxy" },
|
||||
{ "path": "../../packages/host/runtime" },
|
||||
{ "path": "../../packages/host/webserver" },
|
||||
{ "path": "../../packages/core/session" },
|
||||
{ "path": "../../packages/ui/app-boot" },
|
||||
{ "path": "../../packages/util/paths" }
|
||||
{
|
||||
"path": "../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/host/apiproxy"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/host/runtime"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/host/webserver"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/ui/app-boot"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/util/paths"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/connection"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/hmr"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/runtime"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/ui-theme"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/i18n"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/ui-layout"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/ui-sidebar"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/ui-conversation"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/ui-trajectory"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/ui-question"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -20,7 +20,7 @@
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-modules": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-web-react": "workspace:^",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Browser stand-in for `node:module`, mapped by the vite alias in
|
||||
* vite.config.ts (design §2.4). The vendored Loader's internal.ts imports
|
||||
* `createRequire` at module scope but only calls it inside
|
||||
* `ModuleLoader.fromInternal()`, whose version probe is compiled to the
|
||||
* `"0.0.0"` define in the browser build — so this throw is a fail-loud
|
||||
* tripwire for any path that would genuinely need Node's module machinery.
|
||||
*/
|
||||
|
||||
/** Throwing stand-in for node:module's createRequire (never reached in the browser boot). */
|
||||
export const createRequire = (): never => {
|
||||
throw new Error('node:module is not available in the browser')
|
||||
}
|
||||
|
||||
/** Erased type peer for the vendored loader's type-only LoadHookContext import. */
|
||||
export type LoadHookContext = never
|
||||
@@ -3,18 +3,18 @@ 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 { BootPluginEntry } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules'
|
||||
import { bootWebShell } from '@deepseek-ai/dsh-client-web'
|
||||
|
||||
const PLUGINS: readonly (BootPluginEntry & { dir: string })[] = [
|
||||
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', url: '/plugins/i18n.js', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
|
||||
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', url: '/plugins/i18n.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
]
|
||||
|
||||
const bundles = new Map(PLUGINS.map(plugin => [
|
||||
@@ -27,8 +27,8 @@ interface FixtureTiming {
|
||||
}
|
||||
|
||||
interface FixtureWindow extends Window {
|
||||
__DSH_BOOT__?: { plugins: BootPluginEntry[] }
|
||||
DSHClientProxy?: unknown
|
||||
__DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
|
||||
__ModuleLoader__?: unknown
|
||||
}
|
||||
|
||||
class ResizeObserverStub {
|
||||
@@ -51,7 +51,7 @@ beforeEach(() => {
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
setTimeout(() => { callback(0) }, 0) as unknown as number)
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
|
||||
win.__DSH_BOOT__ = { plugins: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
|
||||
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -59,7 +59,7 @@ afterEach(() => {
|
||||
unmount = undefined
|
||||
cleanup()
|
||||
delete win.__DSH_BOOT__
|
||||
delete win.DSHClientProxy
|
||||
delete win.__ModuleLoader__
|
||||
delete (globalThis as Record<string, unknown>).__fxTiming
|
||||
document.body.innerHTML = ''
|
||||
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
|
||||
|
||||
@@ -1,41 +1,67 @@
|
||||
// Keyless boot-chain smoke over the REAL carrier: startWebServer + web-plugins
|
||||
// registry surface + __DSH_BOOT__ injection + built shell dist in a real
|
||||
// chromium. First describe: manifest injection + static serving. Second
|
||||
// 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 DI chain in ?fixture mode, 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.
|
||||
// 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 { WebPluginBootEntry } 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', inject: [], immediately: true },
|
||||
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', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{ 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'] },
|
||||
]
|
||||
|
||||
/** Manifest served by the fake registry: one live bundle row, one missing row. */
|
||||
const ROWS: WebPluginBootEntry[] = [
|
||||
{ id: '@deepseek-ai/dsh-client-ui-layout', url: '/plugins/@deepseek-ai/dsh-client-ui-layout/client.js', inject: [] },
|
||||
{ id: '@probe/absent', url: '/plugins/@probe/absent/client.js', inject: [] },
|
||||
]
|
||||
const LAYOUT_BUNDLE = bundlePath('ui-layout')
|
||||
const BUNDLE_PATHS = new Map(REAL_PLUGINS.map(p => [p.id, bundlePath(p.dir)]))
|
||||
|
||||
const row = (id: string, extra?: Partial<WebBootEntry>): 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<string, string>) {
|
||||
return {
|
||||
graph: () => graph,
|
||||
clientPath: (id: string) => byId.get(id),
|
||||
onRebuilt: () => () => undefined,
|
||||
}
|
||||
}
|
||||
|
||||
describe('web boot chain (keyless, real carrier)', () => {
|
||||
let server: Awaited<ReturnType<typeof startWebServer>>
|
||||
@@ -52,10 +78,7 @@ describe('web boot chain (keyless, real carrier)', () => {
|
||||
port,
|
||||
distIndex: DIST_INDEX,
|
||||
apiHandler,
|
||||
webPlugins: {
|
||||
snapshot: () => ROWS,
|
||||
clientPath: id => (id === ROWS[0]!.id ? LAYOUT_BUNDLE : undefined),
|
||||
},
|
||||
webPlugins: fixedRegistry(FAIL_GRAPH, BUNDLE_PATHS),
|
||||
}, (err) => { pageErrors.push(`server: ${String(err)}`) })
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage()
|
||||
@@ -68,16 +91,25 @@ describe('web boot chain (keyless, real carrier)', () => {
|
||||
await server?.close()
|
||||
})
|
||||
|
||||
it('GET / injects the manifest verbatim', async () => {
|
||||
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({ plugins: ROWS })
|
||||
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}${ROWS[0]!.url}`)
|
||||
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.DSHClientProxy.loadPlugin')
|
||||
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 () => {
|
||||
@@ -87,7 +119,6 @@ describe('web boot chain (keyless, real carrier)', () => {
|
||||
})
|
||||
|
||||
describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', () => {
|
||||
const missing = REAL_PLUGINS.filter(p => !existsSync(bundlePath(p.dir)))
|
||||
let server: Awaited<ReturnType<typeof startWebServer>>
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
@@ -95,14 +126,9 @@ describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', (
|
||||
|
||||
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 <pkg> bundle): ${missing.map(m => m.dir).join(', ')}`)
|
||||
const port = await probeFreePort()
|
||||
const rows: WebPluginBootEntry[] = REAL_PLUGINS.map((p) => {
|
||||
const row: WebPluginBootEntry = { id: p.id, url: `/plugins/${p.id}/client.js`, inject: p.inject }
|
||||
if (p.immediately === true) row.immediately = true
|
||||
return row
|
||||
})
|
||||
const byId = new Map(REAL_PLUGINS.map(p => [p.id, bundlePath(p.dir)]))
|
||||
// ?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({
|
||||
@@ -110,7 +136,7 @@ describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', (
|
||||
port,
|
||||
distIndex: DIST_INDEX,
|
||||
apiHandler,
|
||||
webPlugins: { snapshot: () => rows, clientPath: id => byId.get(id) },
|
||||
webPlugins: fixedRegistry(OK_GRAPH, BUNDLE_PATHS),
|
||||
}, (err) => { pageErrors.push(`server: ${String(err)}`) })
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage()
|
||||
@@ -135,8 +161,8 @@ describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', (
|
||||
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('@deepseek-ai/dsh-client-ui-layout')
|
||||
expect(owners).toContain('@deepseek-ai/dsh-client-ui-sidebar')
|
||||
expect(owners).toContain(LAYOUT_ID)
|
||||
expect(owners).toContain(SIDEBAR_ID)
|
||||
})
|
||||
|
||||
it('collapsed sidebar animates to a 56px rail with the four controls', async () => {
|
||||
|
||||
+12
-3
@@ -9,14 +9,23 @@
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"types": ["node"]
|
||||
"types": [
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"tests"
|
||||
],
|
||||
"references": [
|
||||
{ "path": "../../packages/client/web" },
|
||||
{ "path": "../../packages/host/webserver" }
|
||||
{
|
||||
"path": "../../packages/client/web"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/host/webserver"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/modules"
|
||||
}
|
||||
]
|
||||
}
|
||||
+17
-6
@@ -10,17 +10,28 @@ export default defineConfig({
|
||||
// Workspace packages resolve to SOURCE: package.json exports point at lib
|
||||
// for Node/type consumers, but the browser bundle must compile src directly
|
||||
// so CSS rides vite's pipeline instead of the CSS-externalized lib bundle.
|
||||
// Only the shell's static surface is aliased — UI plugin packages are NOT
|
||||
// bundled here; they arrive as dynamic bundles through the client loader.
|
||||
// Order matters — subpath aliases must win over bare-name prefixes.
|
||||
// Only the shell's normal-package surface is aliased — plugin packages are
|
||||
// NEVER bundled here (web2 shell self-sufficiency); they arrive as runtime
|
||||
// bundles through the client module system. Order matters — subpath
|
||||
// aliases must win over bare-name prefixes.
|
||||
alias: [
|
||||
// Browserization of the vendored cordis Loader: its only node-only
|
||||
// import; the two process probes are mapped by `define` below.
|
||||
{ find: /^node:module$/, replacement: src('./src/node-module-stub.ts') },
|
||||
{ find: /^@deepseek-ai\/dsh-client-web$/, replacement: src('../../packages/client/web/src/boot.tsx') },
|
||||
{ find: /^@deepseek-ai\/dsh-client-web-react\/store$/, replacement: src('../../packages/client/web-react/src/store/index.ts') },
|
||||
{ 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-runtime\/loader$/, replacement: src('../../packages/client/runtime/src/client/loader/index.ts') },
|
||||
{ find: /^@deepseek-ai\/dsh-client-runtime$/, replacement: src('../../packages/client/runtime/src/index.ts') },
|
||||
{ find: /^@deepseek-ai\/dsh-client-modules$/, replacement: src('../../packages/client/modules/src/index.ts') },
|
||||
],
|
||||
},
|
||||
define: {
|
||||
// vendored loader internal.ts: fromInternal() probes the Node major —
|
||||
// "0.0.0" takes neither branch, returning undefined (exactly the empty
|
||||
// internal slot the shell boot fills with the client module loader).
|
||||
'process.versions.node': '"0.0.0"',
|
||||
'process.execArgv': '[]',
|
||||
// vendored loader index.ts: envData falls to its default branch.
|
||||
'process.env.CORDIS_SHARED': 'undefined',
|
||||
},
|
||||
})
|
||||
@@ -1893,6 +1893,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
|
||||
|
||||
- `@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-i18n` ([`packages/client/i18n/src/index.ts`](../packages/client/i18n/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))
|
||||
@@ -1940,6 +1941,7 @@ 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))
|
||||
|
||||
+21
-6
@@ -133,7 +133,9 @@ flowchart TD
|
||||
end
|
||||
subgraph group_client["packages/client"]
|
||||
pkg_client_connection["client-connection"]
|
||||
pkg_client_hmr["client-hmr"]
|
||||
pkg_client_i18n["client-i18n"]
|
||||
pkg_client_modules["client-modules"]
|
||||
pkg_client_runtime["client-runtime"]
|
||||
pkg_client_ui_conversation["client-ui-conversation"]
|
||||
pkg_client_ui_layout["client-ui-layout"]
|
||||
@@ -214,12 +216,10 @@ flowchart TD
|
||||
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
|
||||
pkg_client_ui_conversation --> pkg_invariants
|
||||
pkg_client_ui_layout --> pkg_invariants
|
||||
pkg_client_ui_primitives --> pkg_invariants
|
||||
pkg_client_ui_question --> pkg_invariants
|
||||
pkg_client_ui_sidebar --> pkg_invariants
|
||||
pkg_client_ui_slots --> pkg_invariants
|
||||
pkg_client_ui_theme --> pkg_invariants
|
||||
pkg_client_ui_trajectory --> pkg_invariants
|
||||
@@ -232,6 +232,19 @@ flowchart TD
|
||||
pkg_host_webserver --> pkg_invariants
|
||||
pkg_llm --> pkg_brand
|
||||
pkg_llm --> pkg_invariants
|
||||
pkg_client_hmr --> pkg_client_modules
|
||||
pkg_client_hmr --> pkg_invariants
|
||||
pkg_client_ui_conversation --> pkg_client_runtime
|
||||
pkg_client_ui_conversation --> pkg_client_ui_primitives
|
||||
pkg_client_ui_conversation --> pkg_client_ui_slots
|
||||
pkg_client_ui_conversation --> pkg_invariants
|
||||
pkg_client_ui_layout --> pkg_client_runtime
|
||||
pkg_client_ui_layout --> pkg_client_ui_slots
|
||||
pkg_client_ui_layout --> pkg_invariants
|
||||
pkg_client_ui_sidebar --> pkg_client_runtime
|
||||
pkg_client_ui_sidebar --> pkg_client_ui_primitives
|
||||
pkg_client_ui_sidebar --> pkg_client_ui_slots
|
||||
pkg_client_ui_sidebar --> pkg_invariants
|
||||
pkg_helper --> pkg_brand
|
||||
pkg_helper --> pkg_invariants
|
||||
pkg_telemetry --> pkg_brand
|
||||
@@ -776,12 +789,10 @@ flowchart TD
|
||||
| [`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) |
|
||||
| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-question`](../packages/client/ui-question) | `client` | [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-slots`](../packages/client/ui-slots) | `client` | [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`invariants`](../packages/support/invariants) |
|
||||
@@ -793,6 +804,10 @@ flowchart TD
|
||||
| [`host-runtime`](../packages/host/runtime) | `host` | [`invariants`](../packages/support/invariants) |
|
||||
| [`host-webserver`](../packages/host/webserver) | `host` | [`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-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) |
|
||||
| [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |
|
||||
| [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |
|
||||
| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) |
|
||||
|
||||
@@ -66,15 +66,11 @@
|
||||
},
|
||||
"packages/host/runtime": {
|
||||
"entry": [
|
||||
"tests/**/*.spec.ts",
|
||||
"tests/**/*.e2e.ts"
|
||||
"tests/**/*.spec.ts"
|
||||
],
|
||||
"project": [
|
||||
"src/**/*.ts",
|
||||
"tests/**/*.ts"
|
||||
],
|
||||
"ignoreDependencies": [
|
||||
"@deepseek-ai/dsh-client-.+"
|
||||
]
|
||||
},
|
||||
"packages/client/web-ui": {
|
||||
@@ -391,8 +387,14 @@
|
||||
]
|
||||
},
|
||||
"packages/examples/agent-spine-demo": {
|
||||
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
|
||||
"project": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
"entry": [
|
||||
"tests/**/*.spec.ts",
|
||||
"tests/**/*.e2e.ts"
|
||||
],
|
||||
"project": [
|
||||
"src/**/*.ts",
|
||||
"tests/**/*.ts"
|
||||
]
|
||||
},
|
||||
"packages/ui/jsonrpc": {
|
||||
"entry": [
|
||||
@@ -548,15 +550,15 @@
|
||||
"tests/**/*.tsx"
|
||||
],
|
||||
"ignoreDependencies": [
|
||||
"@deepseek-ai/dsh-client-ui-theme",
|
||||
"@deepseek-ai/dsh-client-connection"
|
||||
"@deepseek-ai/dsh-client-ui-theme"
|
||||
]
|
||||
},
|
||||
"apps/web": {
|
||||
"entry": [
|
||||
"tests/**/*.e2e.ts",
|
||||
"tests/**/*.snapshot.ts",
|
||||
"tests/support.ts"
|
||||
"tests/support.ts",
|
||||
"src/node-module-stub.ts"
|
||||
],
|
||||
"project": [
|
||||
"src/**/*.ts",
|
||||
@@ -571,6 +573,32 @@
|
||||
"react",
|
||||
"react-dom"
|
||||
]
|
||||
},
|
||||
"apps/cli": {
|
||||
"project": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"ignoreDependencies": [
|
||||
"@deepseek-ai/dsh-client-.+"
|
||||
]
|
||||
},
|
||||
"packages/client/modules": {
|
||||
"entry": [
|
||||
"tests/**/*.spec.ts"
|
||||
],
|
||||
"project": [
|
||||
"src/**/*.ts",
|
||||
"tests/**/*.ts"
|
||||
]
|
||||
},
|
||||
"packages/client/hmr": {
|
||||
"entry": [
|
||||
"tests/**/*.spec.ts"
|
||||
],
|
||||
"project": [
|
||||
"src/**/*.ts",
|
||||
"tests/**/*.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,7 @@
|
||||
"demo:cordis": "node --import tsx packages/examples/tui-demo/src/bin.ts examples/cordis-agent/cordis.yml",
|
||||
"demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml",
|
||||
"demo:web": "npm run build && npm run build:web && node --import tsx apps/cli/src/bin.ts web",
|
||||
"dev:web": "tsx scripts/dev-web.ts --poll",
|
||||
"postinstall": "node scripts/install-lefthook.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -14,7 +14,10 @@ export type {
|
||||
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
|
||||
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
export { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
// transportError moved down to the apiproxy api layer (it belongs beside
|
||||
// RpcResult, its subject); re-exported here so connection consumers keep one
|
||||
// contract entry point.
|
||||
export { RpcId, transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
export { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
|
||||
export type { IApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
|
||||
export type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
@@ -31,16 +34,3 @@ import type { RpcResponse, RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
export function resultOf<T>(response: RpcResponse<T>): RpcResult<T> {
|
||||
return response.result
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a transport exception into the RpcResult error branch (unified error
|
||||
* surface; 'internal' as the catch-all code).
|
||||
* @param error - the thrown value from the carrier.
|
||||
* @returns the error branch of an RpcResult.
|
||||
*/
|
||||
export function transportError<T>(error: unknown): RpcResult<T> {
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# @deepseek-ai/dsh-client-hmr
|
||||
|
||||
Hot reload for fetch-arrival client plugins. A static-arrival entry composed only into `--dev` graphs (`dsh web --dev`); production graphs omit the row, so the shell-bundled code stays inert.
|
||||
|
||||
The plugin subscribes to the webserver's system SSE channel (`GET /plugins/events`) and reloads one plugin per `rebuilt` frame, serialized through a queue (the bundle handoff slot is single). The sequence per frame — `prefetch` (fetch the new bundle before touching anything), `invalidate`, `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `<style data-plugin>` tags, `entry.refresh()` re-imports and remounts, `fiber.await()` rethrows startup failures loud. Dependents reload through cordis itself: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber cascades every dependent with zero client-side graph analysis. Rebuild detection lives on the webserver: in dev mode it stat-polls each plugin's built `lib/client.js` (`fs.watchFile`) and broadcasts the `rebuilt` frame when the bundle's rev changes, so any tsdown watch process producing the bundle triggers HMR with no builder→host channel.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the reload driver is browser-side machinery; nothing here reaches a model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Reload is coarse by design** — a fresh fiber and fresh components; React state inside the reloaded plugin is lost while the data layer (connection/runtime fibers, Session objects) is untouched. react-refresh-grade state preservation conflicts with "re-executing the bundle re-runs the factory" and is deliberately out.
|
||||
- **No failure rollback** — a reload that fails leaves the entry FAILED and loud in the loader status projection; restoring the previous bundle automatically is deferred until a real need shows.
|
||||
- **Graph rev is not refreshed by rebuilt frames** — the stale rev is harmless (the bundle endpoint serves no-cache); rev refresh lands with the reconnect-handshake mechanism.
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-hmr",
|
||||
"description": "Dev-only hot-reload driver for fetch-arrival client entries: SSE rebuilt frames → prefetch/invalidate → fiber swap through the vendored Loader entry",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-client-modules": "^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-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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* client-hmr, browser half: hot-reload driver for client plugin entries.
|
||||
*
|
||||
* Listens on the host's system SSE channel (`GET /plugins/events`); on a
|
||||
* `rebuilt` frame it re-fetches the entry's bundle and swaps the cordis
|
||||
* fiber in place. Every graph entry is a plugin bundle under the web2 model
|
||||
* — `immediately` rows differ only in stage-one prefetch (a boot
|
||||
* optimization), so all nine plugin packages share these reload semantics;
|
||||
* normal packages (react family, cordis, shell, pure libs) are not entries
|
||||
* and shell changes still mean a page reload. Cascade is zero-touch:
|
||||
* downstream fibers key their activation epoch on provider fiber uids
|
||||
* (vendor/cordis/src/fiber.ts `_refresh`), so replacing a provider fiber
|
||||
* re-cascades natively — reloading a data-layer plugin (connection/runtime)
|
||||
* cascades into its UI dependents with no HMR-side bookkeeping.
|
||||
*
|
||||
* Reload order (lazy CJS table): invalidate (drop the stale factory and
|
||||
* materialized record) → prefetch (fetch + execute + register the fresh
|
||||
* factory) → registry-first teardown → drain old fiber unload → remove
|
||||
* owned `<style data-plugin>` tags → `entry.refresh()` materializes the new
|
||||
* factory. Invalidate MUST precede prefetch: a live factory makes prefetch
|
||||
* a no-op, and re-executing a bundle over an undeleted registration is a
|
||||
* loud duplicate. The swap is safe because execution is pure registration
|
||||
* under the lazy model — every module side effect (CSS injection included)
|
||||
* lives in the factory closure and runs at materialization, inside
|
||||
* refresh(). That also keeps the CSS ordering guarantee: owned styles are
|
||||
* removed after the old fiber's disposers drained (SlotCore one-owner
|
||||
* unregister) and before materialization re-injects tags under the same
|
||||
* stable tag ids.
|
||||
*
|
||||
* Failure window: if prefetch rejects after invalidate, the module is left
|
||||
* unregistered while the OLD fiber keeps running untouched (teardown never
|
||||
* started) — degraded but recoverable, the next rebuilt frame retries from
|
||||
* scratch. Consistent with the v1 no-rollback policy below. Known dev-only
|
||||
* race: a rebuilt frame overlapping a still-in-flight boot arrival shares
|
||||
* that arrival's task and may materialize the pre-rebuild bytes; the next
|
||||
* rebuilt frame self-heals.
|
||||
*
|
||||
* Why not the naive `entry.fiber.dispose()` → `entry.refresh()` path —
|
||||
* confirmed against vendor sources:
|
||||
* 1. `Entry.fiber` is never cleared on dispose (vendor/loader/src/config/
|
||||
* entry.ts assigns it only in `_init`), so `refresh()` hits its
|
||||
* `if (this.fiber) return` guard and no-ops.
|
||||
* 2. A bare `fiber.dispose()` lands in Loader's self-dispose branch
|
||||
* (vendor/loader/src/index.ts `internal/plugin` case 4: the registry
|
||||
* still holds the runtime at emit time), which flags the entry
|
||||
* `disabled: true` — permanently.
|
||||
* vendor/hmr's reload skeleton documents the fix: delete the runtime record
|
||||
* FIRST (`registry.delete` → case 4 returns early, the entry stays enabled),
|
||||
* then rebuild. We additionally clear `entry.fiber` ourselves so
|
||||
* `entry.refresh()` re-imports and re-plugins through the Loader's own
|
||||
* `_init` (entry-resolved config, automatic `fiber.entry` rebinding) instead
|
||||
* of hand-rolling `registry.plugin`. Client entries have exactly one fiber
|
||||
* per runtime, so `registry.delete` never collaterally disposes siblings.
|
||||
*
|
||||
* Self-reload: this plugin is itself a graph entry, so a rebuilt frame may
|
||||
* name it. The in-flight reload keeps running in the old bundle's closure
|
||||
* (its EventSource closes with the old fiber's effects); the new bundle's
|
||||
* apply opens a fresh channel. Frames arriving during the gap are lost —
|
||||
* acceptable for the dev channel, the next rebuild renotifies.
|
||||
*
|
||||
* Failure policy (v1): no rollback. An import failure leaves the entry
|
||||
* fiberless (the next rebuilt frame retries from scratch); an apply failure
|
||||
* leaves a FAILED fiber for the shell's status projection. Both log loudly.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { Entry, Loader } from '@cordisjs/plugin-loader'
|
||||
import type { WebBootGraph } from '@deepseek-ai/dsh-client-modules'
|
||||
|
||||
/**
|
||||
* 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'
|
||||
|
||||
/** Cordis plugin name. */
|
||||
export const name = 'client-hmr'
|
||||
|
||||
/** Required services: the vendored Loader (entry governance) and the client module system (boot provide, service name `modules`). */
|
||||
export const inject = ['loader', 'modules']
|
||||
|
||||
/** Find the loader entry whose module specifier is `id` (entry tree ids are random; the package name lives in `options.name`). */
|
||||
function findEntry(loader: Loader, id: string): Entry | undefined {
|
||||
for (const entry of loader.entries()) {
|
||||
if (entry.options.name === id) return entry
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Remove every `<style data-plugin>` tag owned by `id` (attribute compared verbatim — no CSS-selector escaping pitfalls). */
|
||||
function removeOwnedStyles(id: string): void {
|
||||
for (const el of document.querySelectorAll('style[data-plugin]')) {
|
||||
if (el.getAttribute('data-plugin') === id) el.remove()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the HMR driver: subscribe to the system SSE channel and hot-swap
|
||||
* rebuilt entries.
|
||||
* @param ctx - plugin context with `loader` and `modules` available.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
// Both are declared injections (typed Context merges: `modules` from the
|
||||
// client module loader package, `loader` from the vendored Loader).
|
||||
const modLoader = ctx.modules
|
||||
const loader: Loader = ctx.loader
|
||||
|
||||
async function reload(id: string): Promise<void> {
|
||||
const entry = findEntry(loader, id)
|
||||
if (entry === undefined) {
|
||||
ctx.logger.warn(`client-hmr: rebuilt frame for unknown entry "${id}" (not in the loader tree)`)
|
||||
return
|
||||
}
|
||||
// Invalidate first (drop stale factory + record — a live factory makes
|
||||
// prefetch a no-op and re-registration a loud duplicate), then run the
|
||||
// async half while the old fiber still serves: fetch + execute registers
|
||||
// the fresh factory with zero side effects (lazy CJS — module bodies run
|
||||
// at materialization, not execution).
|
||||
modLoader.invalidate(id)
|
||||
await modLoader.prefetch(id)
|
||||
|
||||
const oldFiber = entry.fiber
|
||||
if (oldFiber !== undefined) {
|
||||
// Registry-first teardown (see module comment): the runtime record must
|
||||
// be gone before the fiber's disposer emits internal/plugin, or the
|
||||
// Loader flags the entry disabled.
|
||||
const runtime = oldFiber.runtime
|
||||
if (runtime !== null) entry.ctx.registry.delete(runtime.callback)
|
||||
// Drain the unload: effect disposers (slots, subscriptions) must finish
|
||||
// before the new bundle executes and the new apply re-registers.
|
||||
while (oldFiber.inertia !== undefined) await oldFiber.inertia
|
||||
delete entry.fiber
|
||||
}
|
||||
// Old owned styles go before materialization re-injects them (the CSS
|
||||
// idempotency guard keys on stable tag ids).
|
||||
removeOwnedStyles(id)
|
||||
// Re-init through the entry: fiber cleared above, so refresh() re-imports
|
||||
// — materializing the prefetched factory (CSS injects here) — and
|
||||
// re-plugins under the entry context. Import failures are logged by
|
||||
// Entry._init and leave the entry fiberless (retryable).
|
||||
await entry.refresh()
|
||||
// Surface apply failures loudly (v1: no rollback, FAILED state stays).
|
||||
await entry.fiber?.await()
|
||||
}
|
||||
|
||||
// Serialize reloads: frames can arrive faster than a swap completes, and
|
||||
// interleaved dispose/execute chains would corrupt the single-slot handoff.
|
||||
let queue: Promise<void> = Promise.resolve()
|
||||
const handle = (frame: PluginsEventFrame): void => {
|
||||
switch (frame.type) {
|
||||
case 'rebuilt':
|
||||
queue = queue.then(() => reload(frame.id)).catch((error: unknown) => {
|
||||
ctx.logger.error(`client-hmr: reload of "${frame.id}" failed`)
|
||||
ctx.logger.error(error)
|
||||
})
|
||||
break
|
||||
case 'graph':
|
||||
// Connect-time snapshot, unused in v1. The loader's cached graph rev
|
||||
// goes stale after rebuilds — harmless, since prefetch hits the
|
||||
// network anyway (host serves bundles no-cache); graph rev refresh
|
||||
// lands with the reconnect-handshake mechanism.
|
||||
break
|
||||
default:
|
||||
// Merge-extensible frame union: unknown frame types from newer hosts
|
||||
// are ignored by design.
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
ctx.effect(() => {
|
||||
const source = new EventSource(EVENTS_ENDPOINT)
|
||||
source.addEventListener('message', (event: MessageEvent<string>) => {
|
||||
let frame: PluginsEventFrame
|
||||
try {
|
||||
frame = JSON.parse(event.data) as PluginsEventFrame
|
||||
} catch {
|
||||
// Wire boundary: a malformed dev-channel frame is dropped loudly.
|
||||
ctx.logger.warn(`client-hmr: unparseable event frame: ${event.data}`)
|
||||
return
|
||||
}
|
||||
handle(frame)
|
||||
})
|
||||
return () => { source.close() }
|
||||
}, 'client-hmr: event source')
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for the HMR plugin. */
|
||||
export function apply(): void {}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-hmr`.
|
||||
* @module @deepseek-ai/dsh-client-hmr/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-hmr'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-hmr-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { apply } from '@deepseek-ai/dsh-client-hmr'
|
||||
|
||||
describe('hmr node half', () => {
|
||||
it('apply is a no-op host placeholder', () => {
|
||||
apply()
|
||||
expect(true).toBe(true) // reaching here without throw is the contract
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"lib": [
|
||||
"ES2024",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"types": []
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/loader"
|
||||
},
|
||||
{
|
||||
"path": "../modules"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-hmr', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
@@ -27,10 +27,6 @@
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^"
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# @deepseek-ai/dsh-client-modules
|
||||
|
||||
Client module system: the browser peer of Node's internal ESM loader, built as a lazy CJS table. The web shell mounts the vendored cordis Loader for entry governance (fiber lifecycle, inject waiting, update/refresh) and injects this package's `ClientModuleLoader` as its `internal` seam — the vendored side's only consumption point is `EntryTree.import`, so replacing `internal` replaces exactly "how plugin code arrives" and nothing else.
|
||||
|
||||
Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`window.__ModuleLoader__.load({id, factory})`); every module body side effect — CSS injection included — lives in the factory closure and runs at materialization (`factory(require)` → export surface, memoized in `loadCache`), not at script execution. A factory that requires another registered-but-unmaterialized module materializes it recursively, so load order needs no external sequencing; require cycles throw (factory-form CJS cannot deliver partial exports). `<id>/client` and the bare id name the same surface (a plugin bundle IS its package's client half).
|
||||
|
||||
Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → surface; shell-own static registry (`registerStatic`, app-shell) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → fetch + execute + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the fetch branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (fetch + execute, registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and the materialized record so the next prefetch/import refetches (the HMR hook).
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the module loader is browser-side kernel machinery; nothing here reaches a model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Flat module graph by design** — every bundle is one module node whose edges point only at table leaves; the interface (loadCache/edges/invalidate) is shaped for a general module graph so the externalization granularity can change without an interface change.
|
||||
- **No unload bookkeeping of its own** — style removal and fiber teardown ordering live with the HMR driver (`@deepseek-ai/dsh-client-hmr`); the loader only inventories owned style tag ids per record.
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"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)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* 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.
|
||||
* @module @deepseek-ai/dsh-client-modules
|
||||
*/
|
||||
|
||||
import { ClientModuleLoaderImpl } from './loader.ts'
|
||||
|
||||
export { ClientModuleLoaderImpl }
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** The client module system the web shell provides at boot (contract C5). */
|
||||
modules: ClientModuleLoader
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One composed client entry pushed by the host (web2 §0 graph row).
|
||||
* `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).
|
||||
*
|
||||
* Wire contract, held on both sides: the producing peer lives in
|
||||
* `@deepseek-ai/dsh-host-webserver` (host packages keep zero workspace
|
||||
* dependencies, so neither side imports the other's shape — drift between
|
||||
* the two declarations is a bug against the web2 contract).
|
||||
*/
|
||||
export interface WebBootEntry {
|
||||
/** Entry name == package name (or a shell-owned pseudo id, e.g. app-shell). */
|
||||
id: string
|
||||
/**
|
||||
* Bundle endpoint, '/plugins/<id>/client.js?rev=<rev>'. Absent only on
|
||||
* shell-owned pseudo rows (app-shell) whose module is statically registered
|
||||
* — a row that is neither fetchable nor static-registered fails loud.
|
||||
*/
|
||||
url?: string
|
||||
/** Bundle content hash (cache-busting consistency anchor); absent with url. */
|
||||
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__` (dual-held wire contract — see {@link WebBootEntry}). */
|
||||
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 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<string, unknown>
|
||||
}
|
||||
|
||||
/** Window surface this loader owns (bundle side of the handoff protocol) plus the host-injected graph. */
|
||||
export interface DshWindow {
|
||||
/** Host-composed entry graph, injected before the shell bundle runs. */
|
||||
__DSH_BOOT__?: WebBootGraph
|
||||
/** Bundle registration sink; installed once per page by {@link createClientModuleLoader} (contract C6). */
|
||||
__ModuleLoader__?: { load(handoff: ClientPluginHandoff): void }
|
||||
}
|
||||
|
||||
/** 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 `<style data-plugin>` tag ids (`data-plugin-css` values) injected during materialization. */
|
||||
styles: string[]
|
||||
/** Observed `require()` edges (module-graph seam; only table words can appear today). */
|
||||
edges: Set<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* The internal-seam subset the vendored Loader and the client HMR plugin
|
||||
* consume. Mounted on `ctx.loader.internal` by the shell boot and provided
|
||||
* as `ctx.modules` (contract C5).
|
||||
*/
|
||||
export interface ClientModuleLoader {
|
||||
/** Discriminant against Node's internal loader shapes ('v1'/'v2'). */
|
||||
version: 'client'
|
||||
/** Materialized-module registry: id → record. The governance-side read face for entry export surfaces. */
|
||||
loadCache: Map<string, ClientModuleRecord>
|
||||
/**
|
||||
* Internal seam consumed by the vendored Loader's `tree.import`. Resolves
|
||||
* `specifier` through the branch order documented on the module, fetching
|
||||
* and executing a bundle when needed.
|
||||
* @param specifier - module specifier (entry name or table word).
|
||||
* @param parentURL - importer URL (unused — the client module graph is flat).
|
||||
* @param attrs - import attributes (unused; interface parity with Node's seam).
|
||||
* @returns the module's export surface.
|
||||
*/
|
||||
import(specifier: string, parentURL: string, attrs: Record<string, unknown>): Promise<unknown>
|
||||
/**
|
||||
* Register a shell-own module (app-shell — code that ships inside the shell
|
||||
* bundle and never arrives as a plugin bundle).
|
||||
* @param id - entry name (shell-owned pseudo id).
|
||||
* @param module - the statically imported module namespace.
|
||||
*/
|
||||
registerStatic(id: string, module: unknown): void
|
||||
/**
|
||||
* Stage-one arrival: fetch the entry's bundle and execute it, registering
|
||||
* its factory (no materialization — module side effects wait for import).
|
||||
* No-op for static-registered ids and ids whose factory is already
|
||||
* registered; concurrent calls share one in-flight task. To force a fresh
|
||||
* fetch (HMR), {@link invalidate} first.
|
||||
* @param id - graph entry name.
|
||||
*/
|
||||
prefetch(id: string): Promise<void>
|
||||
/**
|
||||
* Full reset of one module: drop its registered factory, its materialized
|
||||
* record, and any consumed bundle text, so the next prefetch/import
|
||||
* refetches and re-executes (the HMR invalidation hook).
|
||||
* @param id - entry name to invalidate.
|
||||
*/
|
||||
invalidate(id: string): void
|
||||
}
|
||||
|
||||
/** Options for {@link createClientModuleLoader} (assembled by the web shell at boot). */
|
||||
export interface ClientModuleLoaderOptions {
|
||||
/** Host-composed entry graph. */
|
||||
graph: WebBootGraph
|
||||
/** Module-table seed: platform-singleton specifier → shell instance. */
|
||||
staticModules: Record<string, unknown>
|
||||
/** Bundle fetch seam (parallelizable half). Defaults to same-origin fetch().text(). */
|
||||
fetchBundle?: (url: string) => Promise<string>
|
||||
/**
|
||||
* Bundle execution seam (synchronously performs the load() registration).
|
||||
* Defaults to a <script> element carrying the code.
|
||||
*/
|
||||
executeBundle?: (code: string, url: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the client module system.
|
||||
* @param options - entry graph, module-table staticModules, fetch/execute seams.
|
||||
* @returns the loader the shell mounts as `ctx.loader.internal` and provides as `ctx.modules`.
|
||||
*/
|
||||
export function createClientModuleLoader(options: ClientModuleLoaderOptions): ClientModuleLoader {
|
||||
return new ClientModuleLoaderImpl(options)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-modules`.
|
||||
* @module @deepseek-ai/dsh-client-modules/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-modules'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-modules-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the module loader is pre-plugin kernel machinery —
|
||||
* it emits no cordis events (the vendored Loader owns entry lifecycle events)
|
||||
* and its mutable state (loadCache, handoff slot) lives below the plugin
|
||||
* layer where invariant observers cannot mount before it runs; resolve branch
|
||||
* order and handoff discipline are asserted by the web boot specs against the
|
||||
* real execution path.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* ClientModuleLoaderImpl — the implementation behind the {@link ClientModuleLoader}
|
||||
* seam. The conceptual contract (lazy CJS model, resolution branch order) is
|
||||
* documented on the package module and the public interfaces in `./index.ts`;
|
||||
* this file owns the state tables and the fetch/execute/materialize machinery.
|
||||
*/
|
||||
import type {
|
||||
ClientModuleLoader, ClientModuleLoaderOptions, ClientModuleRecord,
|
||||
ClientPluginHandoff, DshWindow, WebBootEntry,
|
||||
} from './index.ts'
|
||||
|
||||
/** A registered-but-unmaterialized bundle: the factory plus its source URL (diagnostics). */
|
||||
interface RegisteredFactory {
|
||||
factory: ClientPluginHandoff['factory']
|
||||
url: string
|
||||
}
|
||||
|
||||
/** Default bundle fetch seam: same-origin fetch().text(). */
|
||||
const defaultFetchBundle = async (url: string): Promise<string> => {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) throw new Error(`client-modules: bundle fetch ${url} answered ${String(res.status)}`)
|
||||
return res.text()
|
||||
}
|
||||
|
||||
/** Default bundle execution seam: a <script> element carrying the code. */
|
||||
const defaultExecuteBundle = (code: string, url: string): void => {
|
||||
const el = document.createElement('script')
|
||||
// Inline execution (not src) so the fetch half stays parallelizable; the
|
||||
// sourceURL comment keeps devtools stack frames attributed to the bundle.
|
||||
el.textContent = `${code}\n//# sourceURL=${url}`
|
||||
document.head.appendChild(el)
|
||||
// Execution is synchronous for inline scripts: the factory is registered by
|
||||
// now, so the node (and its source text) has no further job. Removing it
|
||||
// keeps repeated HMR rebuilds from accumulating dead script nodes.
|
||||
el.remove()
|
||||
}
|
||||
|
||||
const urlOf = (row: WebBootEntry): string => {
|
||||
// url is conditional on the wire (shell-own pseudo rows omit it); those
|
||||
// ids resolve through the static registry and never reach a fetch.
|
||||
if (row.url === undefined) throw new Error(`client-modules: entry "${row.id}" has no bundle url and no static registration`)
|
||||
return row.url
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin bundle IS its package's client half: `<id>/client` (the exports
|
||||
* subpath external bundles emit) and the bare graph id name the same
|
||||
* surface, so table lookups normalize the suffix away.
|
||||
*/
|
||||
const stripClientSuffix = (spec: string): string =>
|
||||
spec.endsWith('/client') ? spec.slice(0, -'/client'.length) : spec
|
||||
|
||||
/**
|
||||
* Claim and inventory the <style> tags a factory injected during
|
||||
* materialization: preset-emitted tags arrive pre-tagged with data-plugin;
|
||||
* any untagged tag is claimed for the materializing plugin (HMR bookkeeping).
|
||||
*/
|
||||
const claimStyles = (id: string): string[] => {
|
||||
if (typeof document === 'undefined') return []
|
||||
for (const el of document.querySelectorAll('style:not([data-plugin])')) {
|
||||
el.setAttribute('data-plugin', id)
|
||||
}
|
||||
const owned: string[] = []
|
||||
for (const el of document.querySelectorAll(`style[data-plugin=${JSON.stringify(id)}]`)) {
|
||||
owned.push(el.getAttribute('data-plugin-css') ?? id)
|
||||
}
|
||||
return owned
|
||||
}
|
||||
|
||||
/**
|
||||
* The client module system: state tables plus the arrival/materialization
|
||||
* machinery implementing {@link ClientModuleLoader} (whose members carry the
|
||||
* seam contract docs). Construction indexes the boot graph and installs the
|
||||
* `window.__ModuleLoader__` registration sink (contract C6) — once per page.
|
||||
*/
|
||||
export class ClientModuleLoaderImpl implements ClientModuleLoader {
|
||||
readonly version = 'client'
|
||||
readonly loadCache = new Map<string, ClientModuleRecord>()
|
||||
|
||||
private readonly seed: Map<string, unknown>
|
||||
private readonly statics = new Map<string, unknown>()
|
||||
private readonly factories = new Map<string, RegisteredFactory>()
|
||||
/** In-flight prefetch (fetch + execute) per id; concurrent callers share it. */
|
||||
private readonly pendingArrival = new Map<string, Promise<void>>()
|
||||
/** Materialization re-entrancy guard: factory-form CJS cannot deliver partial exports, so a cycle is fatal. */
|
||||
private readonly materializing = new Set<string>()
|
||||
private readonly graphRows = new Map<string, WebBootEntry>()
|
||||
// Execution URL of the bundle currently being executed (bound into the
|
||||
// factory registration so diagnostics can name the source).
|
||||
private executingUrl = ''
|
||||
// Graph id of the row currently being executed ('' outside arrive):
|
||||
// the load sink cross-checks the handoff id against it so a mis-stamped
|
||||
// bundle cannot register under another entry's identity.
|
||||
private executingId = ''
|
||||
|
||||
private readonly fetchBundle: (url: string) => Promise<string>
|
||||
private readonly executeBundle: (code: string, url: string) => void
|
||||
|
||||
/**
|
||||
* Build the module system over the host graph.
|
||||
* @param options - entry graph, module-table staticModules, fetch/execute seams.
|
||||
*/
|
||||
constructor(options: ClientModuleLoaderOptions) {
|
||||
this.seed = new Map(Object.entries(options.staticModules))
|
||||
this.fetchBundle = options.fetchBundle ?? defaultFetchBundle
|
||||
this.executeBundle = options.executeBundle ?? defaultExecuteBundle
|
||||
|
||||
for (const entry of options.graph.entries) {
|
||||
if (this.graphRows.has(entry.id)) throw new Error(`client-modules: duplicate graph entry "${entry.id}"`)
|
||||
this.graphRows.set(entry.id, entry)
|
||||
}
|
||||
|
||||
const win = globalThis as DshWindow
|
||||
if (win.__ModuleLoader__ !== undefined) throw new Error('client-modules: window.__ModuleLoader__ already installed (double boot?)')
|
||||
win.__ModuleLoader__ = {
|
||||
load: (handoff: ClientPluginHandoff): void => {
|
||||
// Registration is keyed by the handoff id; a duplicate means a bundle
|
||||
// executed twice without an invalidate — always a bug, always loud.
|
||||
if (this.factories.has(handoff.id)) throw new Error(`client-modules: duplicate factory registration for "${handoff.id}" (bundle executed twice without invalidate?)`)
|
||||
// A fetched row's bundle must register the id its row names — a
|
||||
// mis-stamped bundle registering under another entry's identity
|
||||
// would let that entry silently materialize foreign exports.
|
||||
if (this.executingId !== '' && handoff.id !== this.executingId) {
|
||||
throw new Error(`client-modules: bundle ${this.executingUrl} registered "${handoff.id}" while arriving for "${this.executingId}" (mis-stamped bundle id)`)
|
||||
}
|
||||
this.factories.set(handoff.id, { factory: handoff.factory, url: this.executingUrl })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch + execute one graph row so its factory is registered (idempotent per in-flight arrival). */
|
||||
private arrive(row: WebBootEntry): Promise<void> {
|
||||
const { id } = row
|
||||
const pending = this.pendingArrival.get(id)
|
||||
if (pending !== undefined) return pending
|
||||
if (this.factories.has(id)) return Promise.resolve()
|
||||
const task = (async (): Promise<void> => {
|
||||
const url = urlOf(row)
|
||||
const code = await this.fetchBundle(url)
|
||||
this.executingUrl = url
|
||||
this.executingId = id
|
||||
try {
|
||||
this.executeBundle(code, url)
|
||||
} finally {
|
||||
this.executingUrl = ''
|
||||
this.executingId = ''
|
||||
}
|
||||
if (!this.factories.has(id)) {
|
||||
throw new Error(`client-modules: bundle ${url} executed without registering "${id}" via __ModuleLoader__.load`)
|
||||
}
|
||||
})().finally(() => { this.pendingArrival.delete(id) })
|
||||
this.pendingArrival.set(id, task)
|
||||
return task
|
||||
}
|
||||
|
||||
/** Materialize a registered factory (synchronous; memoized in loadCache). */
|
||||
private materialize(id: string): ClientModuleRecord {
|
||||
const existing = this.loadCache.get(id)
|
||||
if (existing !== undefined) return existing
|
||||
const registered = this.factories.get(id)
|
||||
/* v8 ignore next -- callers check the factory branch before dispatching here. */
|
||||
if (registered === undefined) throw new Error(`client-modules: no registered factory for "${id}"`)
|
||||
if (this.materializing.has(id)) {
|
||||
throw new Error(`client-modules: require cycle through "${id}" (factory-form CJS cannot deliver partial exports)`)
|
||||
}
|
||||
this.materializing.add(id)
|
||||
try {
|
||||
const edges = new Set<string>()
|
||||
const surface = registered.factory(this.makeRequire(edges))
|
||||
const record: ClientModuleRecord = { id, surface, styles: claimStyles(id), edges }
|
||||
this.loadCache.set(id, record)
|
||||
return record
|
||||
} finally {
|
||||
this.materializing.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The synchronous require answered to factories: seed → static → memoized
|
||||
* record → registered factory (recursive materialization — this is what
|
||||
* makes load order self-resolving). Fetching is async and therefore
|
||||
* unreachable from here; an unregistered plugin specifier is loud (and a
|
||||
* cross-plugin value import is already a build error upstream).
|
||||
*/
|
||||
private makeRequire(edges: Set<string>): (spec: string) => unknown {
|
||||
return (spec: string): unknown => {
|
||||
edges.add(spec)
|
||||
if (this.seed.has(spec)) return this.seed.get(spec)
|
||||
if (this.statics.has(spec)) return this.statics.get(spec)
|
||||
const id = stripClientSuffix(spec)
|
||||
const record = this.loadCache.get(id)
|
||||
if (record !== undefined) return record.surface
|
||||
if (this.factories.has(id)) return this.materialize(id).surface
|
||||
throw new Error(
|
||||
`client-modules: require("${spec}") missed the module table — not a platform seed word, not a shell-own module, `
|
||||
+ 'and no registered factory (a build-time externals drift, or a forbidden cross-plugin value import)',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async import(specifier: string): Promise<unknown> {
|
||||
if (this.seed.has(specifier)) return this.seed.get(specifier)
|
||||
const existing = this.loadCache.get(specifier)
|
||||
if (existing !== undefined) return existing.surface
|
||||
if (this.statics.has(specifier)) {
|
||||
const surface = this.statics.get(specifier)
|
||||
this.loadCache.set(specifier, { id: specifier, surface, styles: [], edges: new Set() })
|
||||
return surface
|
||||
}
|
||||
if (!this.factories.has(specifier)) {
|
||||
const row = this.graphRows.get(specifier)
|
||||
if (row === undefined) {
|
||||
throw new Error(
|
||||
`client-modules: cannot resolve "${specifier}" — not a seed word, not a shell-own module, `
|
||||
+ 'and not a row in the boot graph (the runtime mirror of the bundle purity gate)',
|
||||
)
|
||||
}
|
||||
await this.arrive(row)
|
||||
}
|
||||
return this.materialize(specifier).surface
|
||||
}
|
||||
|
||||
registerStatic(id: string, module: unknown): void {
|
||||
if (this.statics.has(id)) throw new Error(`client-modules: shell-own module "${id}" registered twice`)
|
||||
this.statics.set(id, module)
|
||||
}
|
||||
|
||||
async prefetch(id: string): Promise<void> {
|
||||
if (this.statics.has(id)) return
|
||||
const row = this.graphRows.get(id)
|
||||
if (row === undefined) throw new Error(`client-modules: prefetch("${id}") — not a graph entry`)
|
||||
await this.arrive(row)
|
||||
}
|
||||
|
||||
invalidate(id: string): void {
|
||||
this.factories.delete(id)
|
||||
this.loadCache.delete(id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* ClientModuleLoaderImpl behavior: lazy CJS arrival (bundle execution only
|
||||
* registers the factory), materialization on first import/require with
|
||||
* memoization and recursive self-sequencing, the resolution branch order,
|
||||
* shared in-flight arrival, invalidate-refetch (HMR), style claiming, the
|
||||
* default transport seams, and the loud failure modes (duplicate
|
||||
* registration, cycles, table misses, double boot).
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ClientModuleLoaderImpl, createClientModuleLoader,
|
||||
type ClientModuleLoader, type ClientPluginHandoff, type DshWindow, type WebBootEntry,
|
||||
} from '../src/index.ts'
|
||||
|
||||
const win = globalThis as DshWindow
|
||||
|
||||
type Factory = ClientPluginHandoff['factory']
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
delete win.__ModuleLoader__
|
||||
delete (document as unknown as Record<string, unknown>).__realmBridge
|
||||
for (const el of document.querySelectorAll('style, script')) el.remove()
|
||||
})
|
||||
|
||||
const row = (id: string): WebBootEntry => ({ id, url: `/plugins/${id}/client.js?rev=0` })
|
||||
|
||||
interface Bench {
|
||||
loader: ClientModuleLoader
|
||||
fetched: string[]
|
||||
gates: Map<string, () => void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Loader over scripted bundles: fetch resolves to the row url (optionally
|
||||
* gated on a release callback); execute registers the scripted factory
|
||||
* through the window sink (`null` scripts a bundle that never calls load).
|
||||
*/
|
||||
function bench(
|
||||
entries: WebBootEntry[],
|
||||
bundles: Record<string, Factory | null> = {},
|
||||
opts: { seed?: Record<string, unknown>; gated?: string[] } = {},
|
||||
): Bench {
|
||||
const fetched: string[] = []
|
||||
const gates = new Map<string, () => void>()
|
||||
const loader = createClientModuleLoader({
|
||||
graph: { rev: 'test', entries },
|
||||
staticModules: opts.seed ?? {},
|
||||
fetchBundle: (url) => {
|
||||
fetched.push(url)
|
||||
if (opts.gated?.includes(url) === true) {
|
||||
return new Promise((resolve) => { gates.set(url, () => { resolve(url) }) })
|
||||
}
|
||||
return Promise.resolve(url)
|
||||
},
|
||||
executeBundle: (code) => {
|
||||
const id = /\/plugins\/(.+)\/client\.js/.exec(code)?.[1]
|
||||
const factory = id === undefined ? undefined : bundles[id]
|
||||
if (factory == null || id === undefined) return
|
||||
win.__ModuleLoader__?.load({ id, factory })
|
||||
},
|
||||
})
|
||||
return { loader, fetched, gates }
|
||||
}
|
||||
|
||||
describe('lazy CJS arrival', () => {
|
||||
it('prefetch fetches and executes but does not run the factory', async () => {
|
||||
const ran: string[] = []
|
||||
const b = bench([row('a')], { a: () => { ran.push('a'); return {} } })
|
||||
await b.loader.prefetch('a')
|
||||
expect(b.fetched).toEqual(['/plugins/a/client.js?rev=0'])
|
||||
expect(ran).toEqual([])
|
||||
expect(b.loader.loadCache.size).toBe(0)
|
||||
})
|
||||
|
||||
it('import materializes once and memoizes the export surface', async () => {
|
||||
const ran: string[] = []
|
||||
const b = bench([row('a')], { a: () => { ran.push('a'); return { marker: 'a' } } })
|
||||
const first = await b.loader.import('a', '', {})
|
||||
const second = await b.loader.import('a', '', {})
|
||||
expect(first).toBe(second)
|
||||
expect((first as { marker: string }).marker).toBe('a')
|
||||
expect(ran).toEqual(['a'])
|
||||
expect(b.loader.loadCache.get('a')?.id).toBe('a')
|
||||
})
|
||||
|
||||
it('import without prefetch fetches, executes, and materializes in one call', async () => {
|
||||
const b = bench([row('a')], { a: () => ({ marker: 'direct' }) })
|
||||
const surface = await b.loader.import('a', '', {})
|
||||
expect((surface as { marker: string }).marker).toBe('direct')
|
||||
expect(b.fetched).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('concurrent callers share one in-flight arrival and materialize once', async () => {
|
||||
const ran: string[] = []
|
||||
const url = '/plugins/a/client.js?rev=0'
|
||||
const b = bench([row('a')], { a: () => { ran.push('a'); return { marker: 'a' } } }, { gated: [url] })
|
||||
const first = b.loader.import('a', '', {})
|
||||
const second = b.loader.import('a', '', {})
|
||||
const third = b.loader.prefetch('a')
|
||||
b.gates.get(url)?.()
|
||||
const [s1, s2] = await Promise.all([first, second, third])
|
||||
expect(s1).toBe(s2)
|
||||
expect(b.fetched).toEqual([url])
|
||||
expect(ran).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('prefetch after registration is a no-op without invalidate', async () => {
|
||||
const b = bench([row('a')], { a: () => ({}) })
|
||||
await b.loader.prefetch('a')
|
||||
await b.loader.prefetch('a')
|
||||
expect(b.fetched).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('require resolution', () => {
|
||||
it('a factory requiring a registered-but-unmaterialized module materializes it recursively', async () => {
|
||||
const order: string[] = []
|
||||
const b = bench([row('a'), row('b')], {
|
||||
a: (req) => {
|
||||
order.push('a')
|
||||
const dep = req('b/client') as { helper: string }
|
||||
return { got: dep.helper }
|
||||
},
|
||||
b: () => { order.push('b'); return { helper: 'from-b' } },
|
||||
})
|
||||
await b.loader.prefetch('a')
|
||||
await b.loader.prefetch('b')
|
||||
const surface = await b.loader.import('a', '', {})
|
||||
expect((surface as { got: string }).got).toBe('from-b')
|
||||
expect(order).toEqual(['a', 'b'])
|
||||
expect(b.loader.loadCache.get('a')?.edges.has('b/client')).toBe(true)
|
||||
expect(b.loader.loadCache.has('b')).toBe(true)
|
||||
})
|
||||
|
||||
it('require prefers the platform seed word over the module table', async () => {
|
||||
const react = { marker: 'react' }
|
||||
const b = bench([row('a')], {
|
||||
a: req => ({ dep: req('react') }),
|
||||
}, { seed: { react } })
|
||||
const surface = await b.loader.import('a', '', {})
|
||||
expect((surface as { dep: unknown }).dep).toBe(react)
|
||||
expect(await b.loader.import('react', '', {})).toBe(react)
|
||||
expect(b.loader.loadCache.has('react')).toBe(false)
|
||||
})
|
||||
|
||||
it('require answers an already-materialized module from the cache', async () => {
|
||||
let built = 0
|
||||
const b = bench([row('a'), row('c')], {
|
||||
a: req => ({ dep: req('c') }),
|
||||
c: () => { built += 1; return { marker: 'c' } },
|
||||
})
|
||||
const c = await b.loader.import('c', '', {})
|
||||
const a = await b.loader.import('a', '', {})
|
||||
expect((a as { dep: unknown }).dep).toBe(c)
|
||||
expect(built).toBe(1)
|
||||
})
|
||||
|
||||
it('a require that misses the module table is loud', async () => {
|
||||
const b = bench([row('a')], { a: req => ({ dep: req('ghost') }) })
|
||||
await expect(b.loader.import('a', '', {})).rejects.toThrow('require("ghost") missed the module table')
|
||||
})
|
||||
|
||||
it('a require cycle is fatal', async () => {
|
||||
const b = bench([row('a'), row('b')], {
|
||||
a: req => ({ dep: req('b') }),
|
||||
b: req => ({ dep: req('a') }),
|
||||
})
|
||||
await b.loader.prefetch('b')
|
||||
await expect(b.loader.import('a', '', {})).rejects.toThrow('require cycle through "a"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('static registry', () => {
|
||||
it('serves shell-own modules to import and require without any fetch', async () => {
|
||||
const shell = { marker: 'app-shell' }
|
||||
const b = bench([row('a'), { id: 'app-shell' }], {
|
||||
a: req => ({ dep: req('app-shell') }),
|
||||
})
|
||||
b.loader.registerStatic('app-shell', shell)
|
||||
await b.loader.prefetch('app-shell')
|
||||
expect(await b.loader.import('app-shell', '', {})).toBe(shell)
|
||||
expect(b.loader.loadCache.get('app-shell')?.styles).toEqual([])
|
||||
expect((await b.loader.import('a', '', {}) as { dep: unknown }).dep).toBe(shell)
|
||||
expect(b.fetched).toEqual(['/plugins/a/client.js?rev=0'])
|
||||
})
|
||||
|
||||
it('duplicate static registration is loud', () => {
|
||||
const b = bench([])
|
||||
b.loader.registerStatic('app-shell', {})
|
||||
expect(() => { b.loader.registerStatic('app-shell', {}) }).toThrow('registered twice')
|
||||
})
|
||||
})
|
||||
|
||||
describe('failure modes', () => {
|
||||
it('duplicate factory registration is loud', () => {
|
||||
bench([])
|
||||
win.__ModuleLoader__?.load({ id: 'x', factory: () => ({}) })
|
||||
expect(() => win.__ModuleLoader__?.load({ id: 'x', factory: () => ({}) }))
|
||||
.toThrow('duplicate factory registration for "x"')
|
||||
})
|
||||
|
||||
it('a bundle that never registers its id is loud', async () => {
|
||||
const b = bench([row('a')], { a: null })
|
||||
await expect(b.loader.import('a', '', {})).rejects.toThrow('without registering "a"')
|
||||
})
|
||||
|
||||
it('an unknown import specifier is loud', async () => {
|
||||
const b = bench([])
|
||||
await expect(b.loader.import('nope', '', {})).rejects.toThrow('cannot resolve "nope"')
|
||||
})
|
||||
|
||||
it('an unknown prefetch id is loud', async () => {
|
||||
const b = bench([])
|
||||
await expect(b.loader.prefetch('nope')).rejects.toThrow('prefetch("nope") — not a graph entry')
|
||||
})
|
||||
|
||||
it('a graph row with no url and no static registration is loud', async () => {
|
||||
const b = bench([{ id: 'ghost' }])
|
||||
await expect(b.loader.import('ghost', '', {})).rejects.toThrow('no bundle url and no static registration')
|
||||
})
|
||||
|
||||
it('a duplicate graph entry is loud at construction', () => {
|
||||
expect(() => bench([row('a'), row('a')])).toThrow('duplicate graph entry "a"')
|
||||
})
|
||||
|
||||
it('double boot is loud', () => {
|
||||
bench([])
|
||||
expect(() => new ClientModuleLoaderImpl({ graph: { rev: 't', entries: [] }, staticModules: {} }))
|
||||
.toThrow('already installed (double boot?)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('HMR reset', () => {
|
||||
it('invalidate drops the factory and record so the module refetches and re-registers', async () => {
|
||||
let generation = 0
|
||||
const b = bench([row('a')], { a: () => ({ generation: ++generation }) })
|
||||
const first = await b.loader.import('a', '', {})
|
||||
b.loader.invalidate('a')
|
||||
expect(b.loader.loadCache.has('a')).toBe(false)
|
||||
await b.loader.prefetch('a')
|
||||
const second = await b.loader.import('a', '', {})
|
||||
expect(b.fetched).toHaveLength(2)
|
||||
expect((first as { generation: number }).generation).toBe(1)
|
||||
expect((second as { generation: number }).generation).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('style claiming', () => {
|
||||
it('claims untagged style tags for the materializing plugin and inventories owned css ids', async () => {
|
||||
const foreign = document.createElement('style')
|
||||
foreign.setAttribute('data-plugin', 'other')
|
||||
document.head.appendChild(foreign)
|
||||
const b = bench([row('a')], {
|
||||
a: () => {
|
||||
document.head.appendChild(document.createElement('style'))
|
||||
const tagged = document.createElement('style')
|
||||
tagged.setAttribute('data-plugin', 'a')
|
||||
tagged.setAttribute('data-plugin-css', 'sheet-1')
|
||||
document.head.appendChild(tagged)
|
||||
return {}
|
||||
},
|
||||
})
|
||||
await b.loader.import('a', '', {})
|
||||
expect(b.loader.loadCache.get('a')?.styles).toEqual(['a', 'sheet-1'])
|
||||
expect(document.querySelectorAll('style[data-plugin="a"]')).toHaveLength(2)
|
||||
expect(foreign.getAttribute('data-plugin')).toBe('other')
|
||||
})
|
||||
|
||||
it('materialization without a document skips the style inventory', async () => {
|
||||
const b = bench([row('a')], { a: () => ({}) })
|
||||
vi.stubGlobal('document', undefined)
|
||||
try {
|
||||
await b.loader.import('a', '', {})
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
expect(b.loader.loadCache.get('a')?.styles).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('default transport seams', () => {
|
||||
it('fetches same-origin and executes through an inline script tag', async () => {
|
||||
// In a browser the loader's globalThis IS the page window; vitest's jsdom
|
||||
// evaluates <script> in a separate realm that shares only the document,
|
||||
// so the fixture bundle restores the sink from a document bridge before
|
||||
// using the normal calling convention.
|
||||
const code = 'window.__ModuleLoader__ = document.__realmBridge;\n'
|
||||
+ 'window.__ModuleLoader__.load({ id: "dee", factory: function () { return { marker: "via-script" } } })'
|
||||
vi.stubGlobal('fetch', async () => ({ ok: true, text: async () => code }))
|
||||
const loader = createClientModuleLoader({ graph: { rev: 't', entries: [row('dee')] }, staticModules: {} })
|
||||
;(document as unknown as Record<string, unknown>).__realmBridge = win.__ModuleLoader__
|
||||
const surface = await loader.import('dee', '', {})
|
||||
expect((surface as { marker: string }).marker).toBe('via-script')
|
||||
// The script node is removed right after its synchronous execution —
|
||||
// repeated HMR rebuilds must not accumulate dead script nodes.
|
||||
expect([...document.querySelectorAll('script')]).toEqual([])
|
||||
})
|
||||
|
||||
it('a non-ok bundle response is loud with the status', async () => {
|
||||
vi.stubGlobal('fetch', async () => ({ ok: false, status: 404 }))
|
||||
const loader = createClientModuleLoader({ graph: { rev: 't', entries: [row('dee')] }, staticModules: {} })
|
||||
await expect(loader.prefetch('dee')).rejects.toThrow('answered 404')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"lib": [
|
||||
"ES2024",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"types": []
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-runtime",
|
||||
"description": "Client cordis boot and core services: SlotsService, SessionsService (scope tree + object layer), ClientLoader",
|
||||
"description": "Client core services: SlotsService, SessionsService (scope tree + object layer)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -15,10 +15,6 @@
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./loader": {
|
||||
"types": "./lib/types/client/loader/index.d.ts",
|
||||
"default": "./lib/loader.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
@@ -37,6 +33,7 @@
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"immer": "^10.1.1",
|
||||
@@ -56,7 +53,6 @@
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/loader.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
|
||||
@@ -2,17 +2,15 @@
|
||||
* Browser half: the whole runtime contract surface (api-contracts v3 §4) —
|
||||
* SlotsService (declaration ledger + renderer seam + store axis, built-in
|
||||
* 'root'), SessionsService (list store + current selection + scope tree +
|
||||
* object layer), the ClientLoader interface, and the cordis Context/Events
|
||||
* merges. apply
|
||||
* mounts ctx.slots + ctx.sessions and wires the connection stream loop into
|
||||
* the object layer. The loader machinery implementation is NOT in the plugin
|
||||
* bundle — it ships via the package's `./loader` subpath, statically held by
|
||||
* the web shell (a loader cannot load itself).
|
||||
* object layer), and the cordis Context/Events merges. apply mounts
|
||||
* ctx.slots + ctx.sessions and wires the connection stream loop into the
|
||||
* object layer. A static-arrival entry: the web shell bundles this module
|
||||
* and mounts it through the host graph (module loading lives in
|
||||
* @deepseek-ai/dsh-client-modules, entry governance in the vendored Loader).
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SnapshotStore } from './contract/store.ts'
|
||||
import { SlotsService } from './slots.ts'
|
||||
import { SessionsService } from './sessions/service.ts'
|
||||
import type { SessionListState } from './sessions/service.ts'
|
||||
@@ -95,48 +93,9 @@ declare module 'cordis' {
|
||||
interface Context {
|
||||
slots: import('./slots.ts').SlotsService
|
||||
sessions: import('./sessions/service.ts').SessionsService
|
||||
loader: ClientLoader
|
||||
}
|
||||
}
|
||||
|
||||
/** One __DSH_BOOT__ manifest row. */
|
||||
export interface BootPluginEntry { id: string; url: string; inject: string[]; immediately?: boolean }
|
||||
|
||||
/** Per-plugin load status store shape. */
|
||||
export type LoaderStatus = Record<string, 'loading' | 'active' | 'failed'>
|
||||
|
||||
/**
|
||||
* Client bundle loader. The immediately group loads first (parallel fetch,
|
||||
* apply in inject topology order); remaining plugins follow in inject
|
||||
* topology. Loaded bundle export surfaces are registered back into the
|
||||
* require module table. Implementation lives in the `./loader` subpath
|
||||
* (shell-held machinery).
|
||||
*/
|
||||
export interface ClientLoader {
|
||||
/** Start loading from window.__DSH_BOOT__ (non-blocking). */
|
||||
start(): void
|
||||
/**
|
||||
* Load one plugin bundle (script inject, factory handoff, ctx.plugin, style registration).
|
||||
* @param id - plugin id (package name).
|
||||
*/
|
||||
load(id: string): Promise<void>
|
||||
/**
|
||||
* Unload a plugin. P-I: not implemented (full chain lands with HMR).
|
||||
* @param id - plugin id.
|
||||
*/
|
||||
unload(id: string): Promise<void>
|
||||
/** Resolves when every manifest plugin reached active (AppRoot gates the real UI on this). */
|
||||
settled(): Promise<void>
|
||||
/**
|
||||
* Read a loaded module's export surface from the module table (same
|
||||
* implementation the bundle-facing require uses; unknown spec throws).
|
||||
* @param spec - module specifier (package name or seeded library id).
|
||||
*/
|
||||
requireModule(spec: string): unknown
|
||||
/** Per-plugin status store. */
|
||||
readonly status: SnapshotStore<LoaderStatus>
|
||||
}
|
||||
|
||||
/** Required services: the wire handle mounted by the connection plugin. */
|
||||
export const inject = ['connection']
|
||||
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
/**
|
||||
* ClientLoader implementation (shell-held machinery — the loader cannot load
|
||||
* itself, so the web shell imports this subpath statically and mounts the
|
||||
* instance as ctx.loader; the runtime package's own client bundle never
|
||||
* includes it).
|
||||
*
|
||||
* Load chain per plugin: fetch bundle text → execute (script injection) → the
|
||||
* bundle calls window.DSHClientProxy.loadPlugin({id, factory}) (single-slot
|
||||
* handoff, id reconciled) → factory(require) with require bound to the module
|
||||
* table → ctx.plugin(exports.apply) → the export surface is registered into
|
||||
* the module table under the plugin id (inject topology guarantees later
|
||||
* loaders can require earlier ones) → <style data-plugin> ownership recorded.
|
||||
*
|
||||
* start(): the `immediately` group is fetched in parallel and executed in
|
||||
* group-internal inject topology (execution is serial — the handoff slot is
|
||||
* single); a full-group barrier precedes the remaining plugins, which then
|
||||
* load one by one in inject topology.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import { createSnapshotStore } from '../contract/store.ts'
|
||||
import type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts'
|
||||
|
||||
export type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts'
|
||||
|
||||
/** The shape a client bundle hands to window.DSHClientProxy.loadPlugin. */
|
||||
export interface ClientPluginHandoff {
|
||||
/** Plugin id (package name) — must match the manifest row being loaded. */
|
||||
id: string
|
||||
/**
|
||||
* Closure factory: receives the DI require and returns the module's export
|
||||
* surface; an `apply` export is applied as a cordis plugin.
|
||||
*/
|
||||
factory: (require: (spec: string) => unknown) => Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Window surface the loader owns (bundle side of the handoff protocol). */
|
||||
interface DshWindow {
|
||||
__DSH_BOOT__?: { plugins: BootPluginEntry[] }
|
||||
DSHClientProxy?: { loadPlugin(handoff: ClientPluginHandoff): void }
|
||||
}
|
||||
|
||||
/** Options for createClientLoader (assembled by the web shell at boot). */
|
||||
export interface ClientLoaderOptions {
|
||||
/** Client root context: plugin applies mount under it. */
|
||||
ctx: Context
|
||||
/**
|
||||
* Seeded module table: pure-library entities (react, react-dom, cordis,
|
||||
* ui-slots, web-react, ui-primitives). The loader takes ownership and
|
||||
* registers loaded bundle export surfaces alongside them.
|
||||
*/
|
||||
modules: Record<string, unknown>
|
||||
/**
|
||||
* Boot manifest; defaults to window.__DSH_BOOT__. Fixture pages inject the
|
||||
* same protocol shape.
|
||||
*/
|
||||
boot?: { plugins: BootPluginEntry[] }
|
||||
/** Bundle fetch seam (parallelizable half). Defaults to same-origin fetch().text(). */
|
||||
fetchBundle?: (url: string) => Promise<string>
|
||||
/**
|
||||
* Bundle execution seam (serial half; execution synchronously performs the
|
||||
* loadPlugin handoff). Defaults to a <script> element carrying the code.
|
||||
*/
|
||||
executeBundle?: (code: string, url: string) => void
|
||||
}
|
||||
|
||||
/** Per-plugin bookkeeping across the load chain. */
|
||||
interface PluginRecord {
|
||||
entry: BootPluginEntry
|
||||
state: 'idle' | 'loading' | 'active' | 'failed'
|
||||
fetch?: Promise<string>
|
||||
load?: Promise<void>
|
||||
}
|
||||
|
||||
const NOT_LOADED = Symbol('dsh.loader.not-loaded')
|
||||
|
||||
/**
|
||||
* Build the client bundle loader.
|
||||
* @param options - ctx, seeded module table, boot manifest, fetch/execute seams.
|
||||
* @returns the ClientLoader the shell mounts as ctx.loader.
|
||||
*/
|
||||
export function createClientLoader(options: ClientLoaderOptions): ClientLoader {
|
||||
const { ctx } = options
|
||||
const win = globalThis as DshWindow
|
||||
const boot = options.boot ?? win.__DSH_BOOT__
|
||||
if (boot === undefined) throw new Error('client-loader: no boot manifest (window.__DSH_BOOT__ missing)')
|
||||
|
||||
const modules = new Map<string, unknown>(Object.entries(options.modules))
|
||||
const records = new Map<string, PluginRecord>()
|
||||
for (const entry of boot.plugins) {
|
||||
if (records.has(entry.id)) throw new Error(`client-loader: duplicate manifest id "${entry.id}"`)
|
||||
records.set(entry.id, { entry, state: 'idle' })
|
||||
}
|
||||
|
||||
const status = createSnapshotStore<LoaderStatus>({})
|
||||
const publish = (id: string, state: 'loading' | 'active' | 'failed'): void => {
|
||||
status.update((draft) => { draft[id] = state })
|
||||
}
|
||||
|
||||
// Single-slot handoff: bundle execution synchronously calls loadPlugin;
|
||||
// doLoad arms the slot before executing and reconciles the id after.
|
||||
let slot: ClientPluginHandoff | typeof NOT_LOADED = NOT_LOADED
|
||||
if (win.DSHClientProxy !== undefined) throw new Error('client-loader: window.DSHClientProxy already installed (double boot?)')
|
||||
win.DSHClientProxy = {
|
||||
loadPlugin: (handoff: ClientPluginHandoff): void => {
|
||||
if (slot !== NOT_LOADED) {
|
||||
throw new Error(`client-loader: overlapping loadPlugin handoff (got "${handoff.id}" while a previous handoff is unclaimed)`)
|
||||
}
|
||||
slot = handoff
|
||||
},
|
||||
}
|
||||
|
||||
const fetchBundle = options.fetchBundle ?? (async (url: string): Promise<string> => {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) throw new Error(`client-loader: bundle fetch ${url} answered ${String(res.status)}`)
|
||||
return res.text()
|
||||
})
|
||||
|
||||
const executeBundle = options.executeBundle ?? ((code: string, url: string): void => {
|
||||
const el = document.createElement('script')
|
||||
// Inline execution (not src) so the fetch half stays parallelizable; the
|
||||
// sourceURL comment keeps devtools stack frames attributed to the bundle.
|
||||
el.textContent = `${code}\n//# sourceURL=${url}`
|
||||
document.head.appendChild(el)
|
||||
})
|
||||
|
||||
const requireModule = (spec: string): unknown => {
|
||||
if (!modules.has(spec)) {
|
||||
throw new Error(`client-loader: module "${spec}" is not available — not a seeded library and no loaded plugin registered it (check dshClient.inject ordering)`)
|
||||
}
|
||||
return modules.get(spec)
|
||||
}
|
||||
|
||||
/** Tag styles the bundle injected during execution (unload bookkeeping; plugin CSS lands untagged). */
|
||||
const claimStyles = (id: string): void => {
|
||||
if (typeof document === 'undefined') return
|
||||
for (const el of document.querySelectorAll('style:not([data-plugin])')) {
|
||||
el.setAttribute('data-plugin', id)
|
||||
}
|
||||
}
|
||||
|
||||
/** Start (or reuse) the parallelizable fetch half. */
|
||||
const prefetch = (record: PluginRecord): Promise<string> =>
|
||||
(record.fetch ??= fetchBundle(record.entry.url))
|
||||
|
||||
async function doLoad(record: PluginRecord): Promise<void> {
|
||||
const { id } = record.entry
|
||||
record.state = 'loading'
|
||||
publish(id, 'loading')
|
||||
try {
|
||||
// Dependencies must already be active (start() sequences this; direct
|
||||
// load() callers get the same fail-loud check).
|
||||
for (const dep of record.entry.inject) {
|
||||
const depRecord = records.get(dep)
|
||||
if (depRecord === undefined) throw new Error(`client-loader: "${id}" injects unknown plugin "${dep}"`)
|
||||
if (depRecord.state !== 'active') throw new Error(`client-loader: "${id}" loaded before its dependency "${dep}" is active`)
|
||||
}
|
||||
const code = await prefetch(record)
|
||||
executeBundle(code, record.entry.url)
|
||||
if (slot === NOT_LOADED) throw new Error(`client-loader: bundle ${record.entry.url} executed without calling DSHClientProxy.loadPlugin`)
|
||||
const handoff = slot
|
||||
slot = NOT_LOADED
|
||||
if (handoff.id !== id) throw new Error(`client-loader: bundle id mismatch — manifest "${id}" vs handoff "${handoff.id}"`)
|
||||
const exports = handoff.factory(requireModule)
|
||||
if (typeof exports.apply !== 'function') throw new Error(`client-loader: plugin "${id}" exports no apply function`)
|
||||
// The whole export surface is the plugin: cordis object-plugin form
|
||||
// keeps the bundle's exported `inject`/`name` (an apply-only pass would
|
||||
// silently drop the dependency declaration — postmortem 0001).
|
||||
const fiber = ctx.plugin(exports as { apply: (ctx: Context) => void })
|
||||
await fiber.await()
|
||||
// Register under both specifier forms bundles emit: the bare package
|
||||
// name (deep-import rewrites) and the /client subpath (CLIENT_EXTERNALS
|
||||
// form) — the loaded surface IS the client half either way.
|
||||
modules.set(id, exports)
|
||||
modules.set(`${id}/client`, exports)
|
||||
claimStyles(id)
|
||||
record.state = 'active'
|
||||
publish(id, 'active')
|
||||
} catch (error) {
|
||||
record.state = 'failed'
|
||||
publish(id, 'failed')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const load = (id: string): Promise<void> => {
|
||||
const record = records.get(id)
|
||||
if (record === undefined) return Promise.reject(new Error(`client-loader: unknown plugin "${id}"`))
|
||||
record.load ??= doLoad(record)
|
||||
return record.load
|
||||
}
|
||||
|
||||
/** Topologically order `ids` by inject (edges inside the set only — an early-group member never waits on a later-group one). */
|
||||
const topo = (ids: string[]): string[] => {
|
||||
const pool = new Set(ids)
|
||||
const ordered: string[] = []
|
||||
const done = new Set<string>()
|
||||
const visiting = new Set<string>()
|
||||
const visit = (id: string): void => {
|
||||
if (done.has(id)) return
|
||||
if (visiting.has(id)) throw new Error(`client-loader: inject cycle through "${id}"`)
|
||||
visiting.add(id)
|
||||
const record = records.get(id)
|
||||
/* v8 ignore next -- ids come from records; unknown ids are caught per-dep below. */
|
||||
if (record === undefined) throw new Error(`client-loader: manifest references unknown plugin "${id}"`)
|
||||
for (const dep of record.entry.inject) {
|
||||
if (!records.has(dep)) throw new Error(`client-loader: "${id}" injects unknown plugin "${dep}"`)
|
||||
if (pool.has(dep)) visit(dep)
|
||||
}
|
||||
visiting.delete(id)
|
||||
done.add(id)
|
||||
ordered.push(id)
|
||||
}
|
||||
for (const id of ids) visit(id)
|
||||
return ordered
|
||||
}
|
||||
|
||||
let settledPromise: Promise<void> | undefined
|
||||
|
||||
async function run(): Promise<void> {
|
||||
const all = [...records.values()]
|
||||
const early = all.filter(r => r.entry.immediately === true)
|
||||
const rest = all.filter(r => r.entry.immediately !== true)
|
||||
// Early group: parallel fetch (all requests in flight at once), serial
|
||||
// inject-topology execution, full-group barrier before anything else.
|
||||
const earlyOrder = topo(early.map(r => r.entry.id))
|
||||
for (const record of early) void prefetch(record).catch(() => {}) // surfaced by the awaited load below
|
||||
for (const id of earlyOrder) await load(id)
|
||||
// Remaining plugins: one by one in inject topology.
|
||||
for (const id of topo(rest.map(r => r.entry.id))) await load(id)
|
||||
}
|
||||
|
||||
return {
|
||||
start: () => {
|
||||
settledPromise ??= run()
|
||||
// Failures surface through settled()/status — start() itself is fire-and-forget.
|
||||
settledPromise.catch(() => {})
|
||||
},
|
||||
load,
|
||||
unload: (id: string) => Promise.reject(new Error(`client-loader: unload("${id}") is not implemented (lands with HMR)`)),
|
||||
settled: () => {
|
||||
if (settledPromise === undefined) throw new Error('client-loader: settled() before start()')
|
||||
return settledPromise
|
||||
},
|
||||
requireModule,
|
||||
status,
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
// List data never enters zustand; React connects via subscribe/getListSnapshot.
|
||||
|
||||
import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { transportError } from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
// plugin-to-plugin value imports are a bundle purity error.
|
||||
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { SessionListEntry, TitledSessionSummary } from './lineage.ts'
|
||||
import { flattenLineage } from './lineage.ts'
|
||||
import { Notifier } from './notifier.ts'
|
||||
|
||||
@@ -168,6 +168,18 @@ export class SessionsService {
|
||||
return this.resolve(id)?.ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the session scope tag off a context. Service-method seam: fetch
|
||||
* bundles must reach scope resolution through ctx.sessions — a cross-bundle
|
||||
* value import of the standalone helper would inline a second module
|
||||
* instance whose private tag Symbol never matches.
|
||||
* @param ctx - any client context.
|
||||
* @returns the session id, or undefined on root contexts.
|
||||
*/
|
||||
scopeOf(ctx: Context): SessionId | undefined {
|
||||
return scopeOf(ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the stable session binding (scope-addressed assembly feed). Pure
|
||||
* resolution — no staging, no window side effects.
|
||||
|
||||
@@ -9,7 +9,9 @@ import type {
|
||||
HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult,
|
||||
SessionId, ToolEventView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { transportError } from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
// plugin-to-plugin value imports are a bundle purity error.
|
||||
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { ObservableSnapshot } from '../contract/store.ts'
|
||||
import type {
|
||||
ConversationNode, ConversationSnapshot, OpenState, PromptError, RunningToolCall,
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
/**
|
||||
* ClientLoader: handoff protocol (single slot, id reconciliation), DI require
|
||||
* with export-surface re-registration, immediately-group barrier (parallel
|
||||
* fetch / topology execution / full-group barrier), status store, settled,
|
||||
* failure modes (missing handoff, unknown dep, cycle, unload stub).
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createClientLoader } from '../src/client/loader/index.ts'
|
||||
import type { BootPluginEntry, ClientPluginHandoff } from '../src/client/loader/index.ts'
|
||||
|
||||
type Win = { DSHClientProxy?: { loadPlugin(h: ClientPluginHandoff): void }; __DSH_BOOT__?: { plugins: BootPluginEntry[] } }
|
||||
const win = globalThis as Win
|
||||
|
||||
afterEach(() => {
|
||||
delete win.DSHClientProxy
|
||||
delete win.__DSH_BOOT__
|
||||
})
|
||||
|
||||
interface FakeBundle {
|
||||
handoff: ClientPluginHandoff | null | ((require: (spec: string) => unknown) => Record<string, unknown>)
|
||||
}
|
||||
|
||||
interface Bench {
|
||||
loader: ReturnType<typeof createClientLoader>
|
||||
fetched: string[]
|
||||
executed: string[]
|
||||
fetchGate: Map<string, () => void>
|
||||
}
|
||||
|
||||
/** Build a loader over scripted fake bundles keyed by url; fetches resolve when released (or immediately). */
|
||||
function bench(
|
||||
plugins: BootPluginEntry[],
|
||||
bundles: Record<string, FakeBundle>,
|
||||
opts: { modules?: Record<string, unknown>; gated?: string[] } = {},
|
||||
): Bench {
|
||||
const ctx = new Context()
|
||||
const fetched: string[] = []
|
||||
const executed: string[] = []
|
||||
const fetchGate = new Map<string, () => void>()
|
||||
const loader = createClientLoader({
|
||||
ctx,
|
||||
modules: opts.modules ?? { react: { marker: 'react' } },
|
||||
boot: { plugins },
|
||||
fetchBundle: (url) => {
|
||||
fetched.push(url)
|
||||
if (opts.gated?.includes(url) === true) {
|
||||
return new Promise<string>((resolve) => { fetchGate.set(url, () => { resolve(url) }) })
|
||||
}
|
||||
return Promise.resolve(url)
|
||||
},
|
||||
executeBundle: (code) => {
|
||||
executed.push(code)
|
||||
const bundle = bundles[code]
|
||||
if (bundle === undefined) throw new Error(`no fake bundle for ${code}`)
|
||||
if (bundle.handoff === null) return // simulates a bundle that never calls loadPlugin
|
||||
if (typeof bundle.handoff === 'function') {
|
||||
win.DSHClientProxy?.loadPlugin({ id: code.replace('/client.js', '').replace('/plugins/', ''), factory: bundle.handoff })
|
||||
return
|
||||
}
|
||||
win.DSHClientProxy?.loadPlugin(bundle.handoff)
|
||||
},
|
||||
})
|
||||
return { loader, fetched, executed, fetchGate }
|
||||
}
|
||||
|
||||
const entry = (id: string, inject: string[] = [], immediately?: boolean): BootPluginEntry =>
|
||||
({ id, url: `/plugins/${id}/client.js`, inject, ...(immediately === true ? { immediately: true } : {}) })
|
||||
|
||||
const okBundle = (applied?: string[], exports: Record<string, unknown> = {}): FakeBundle => ({
|
||||
handoff: require => ({
|
||||
apply: (pluginCtx: Context) => { void pluginCtx; applied?.push('applied') },
|
||||
require,
|
||||
...exports,
|
||||
}),
|
||||
})
|
||||
|
||||
describe('load chain', () => {
|
||||
it('runs fetch→execute→handoff→factory(require)→apply→export re-registration→status active', async () => {
|
||||
const applied: string[] = []
|
||||
const b = bench(
|
||||
[entry('fake-base', [], true), entry('feature', ['fake-base'])],
|
||||
{
|
||||
'/plugins/fake-base/client.js': { handoff: () => ({ apply: () => { applied.push('fake-base') }, helper: 'base-helper' }) },
|
||||
'/plugins/feature/client.js': {
|
||||
handoff: (require) => {
|
||||
// Later loader requires the earlier one's export surface (inject topology guarantee).
|
||||
const fakeBase = ['fake','base'].join('-') // assembled so knip's static require() scan skips the fake id
|
||||
const base = require(fakeBase) as { helper: string }
|
||||
expect(base.helper).toBe('base-helper')
|
||||
expect((require('react') as { marker: string }).marker).toBe('react')
|
||||
return { apply: () => { applied.push('feature') } }
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
b.loader.start()
|
||||
await b.loader.settled()
|
||||
expect(applied).toEqual(['fake-base', 'feature'])
|
||||
expect(b.loader.status.getSnapshot()).toEqual({ 'fake-base': 'active', feature: 'active' })
|
||||
expect((b.loader.requireModule('fake-base') as { helper: string }).helper).toBe('base-helper')
|
||||
expect(() => b.loader.requireModule('ghost')).toThrow(/not available/)
|
||||
})
|
||||
|
||||
it('fetches the immediately group in parallel and holds the barrier before the rest', async () => {
|
||||
const b = bench(
|
||||
[entry('a', [], true), entry('b', ['a'], true), entry('later')],
|
||||
{
|
||||
'/plugins/a/client.js': okBundle(),
|
||||
'/plugins/b/client.js': okBundle(),
|
||||
'/plugins/later/client.js': okBundle(),
|
||||
},
|
||||
{ gated: ['/plugins/a/client.js'] },
|
||||
)
|
||||
b.loader.start()
|
||||
await Promise.resolve()
|
||||
// Both early fetches are in flight before any execution; the late plugin is not fetched yet.
|
||||
expect(b.fetched).toEqual(['/plugins/a/client.js', '/plugins/b/client.js'])
|
||||
expect(b.executed).toEqual([])
|
||||
b.fetchGate.get('/plugins/a/client.js')?.()
|
||||
await b.loader.settled()
|
||||
expect(b.executed).toEqual(['/plugins/a/client.js', '/plugins/b/client.js', '/plugins/later/client.js'])
|
||||
})
|
||||
|
||||
it('orders execution by inject topology within each group', async () => {
|
||||
const b = bench(
|
||||
[entry('z-ui', ['a-base']), entry('a-base')],
|
||||
{ '/plugins/a-base/client.js': okBundle(), '/plugins/z-ui/client.js': okBundle() },
|
||||
)
|
||||
b.loader.start()
|
||||
await b.loader.settled()
|
||||
expect(b.executed).toEqual(['/plugins/a-base/client.js', '/plugins/z-ui/client.js'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('failure modes (fail loud)', () => {
|
||||
it('rejects settled and marks failed when a bundle never calls loadPlugin', async () => {
|
||||
const b = bench([entry('silent')], { '/plugins/silent/client.js': { handoff: null } })
|
||||
b.loader.start()
|
||||
await expect(b.loader.settled()).rejects.toThrow(/without calling DSHClientProxy.loadPlugin/)
|
||||
expect(b.loader.status.getSnapshot().silent).toBe('failed')
|
||||
})
|
||||
|
||||
it('rejects on manifest/handoff id mismatch', async () => {
|
||||
const b = bench([entry('expected')], {
|
||||
'/plugins/expected/client.js': { handoff: { id: 'imposter', factory: () => ({ apply: () => {} }) } },
|
||||
})
|
||||
b.loader.start()
|
||||
await expect(b.loader.settled()).rejects.toThrow(/id mismatch/)
|
||||
})
|
||||
|
||||
it('rejects unknown inject targets, cycles, missing apply, unknown load ids, duplicate manifest ids', async () => {
|
||||
// Sequential benches: each loader owns the window proxy, so release it between them.
|
||||
const fresh = <T>(build: () => T): T => {
|
||||
delete win.DSHClientProxy
|
||||
return build()
|
||||
}
|
||||
|
||||
const missing = fresh(() => bench([entry('x', ['nope'])], { '/plugins/x/client.js': okBundle() }))
|
||||
missing.loader.start()
|
||||
await expect(missing.loader.settled()).rejects.toThrow(/injects unknown plugin "nope"/)
|
||||
|
||||
const cyclic = fresh(() => bench(
|
||||
[entry('p', ['q']), entry('q', ['p'])],
|
||||
{ '/plugins/p/client.js': okBundle(), '/plugins/q/client.js': okBundle() },
|
||||
))
|
||||
cyclic.loader.start()
|
||||
await expect(cyclic.loader.settled()).rejects.toThrow(/inject cycle/)
|
||||
|
||||
const applyless = fresh(() => bench([entry('noap')], { '/plugins/noap/client.js': { handoff: { id: 'noap', factory: () => ({}) } } }))
|
||||
applyless.loader.start()
|
||||
await expect(applyless.loader.settled()).rejects.toThrow(/exports no apply/)
|
||||
|
||||
const b = fresh(() => bench([entry('a')], { '/plugins/a/client.js': okBundle() }))
|
||||
await expect(b.loader.load('ghost')).rejects.toThrow(/unknown plugin "ghost"/)
|
||||
|
||||
expect(() => fresh(() => bench([entry('dup'), entry('dup')], {}))).toThrow(/duplicate manifest id/)
|
||||
})
|
||||
|
||||
it('throws on missing boot manifest, double proxy install, and pre-start settled', () => {
|
||||
expect(() => createClientLoader({ ctx: new Context(), modules: {} })).toThrow(/no boot manifest/)
|
||||
const b = bench([], {})
|
||||
expect(() => b.loader.settled()).toThrow(/settled\(\) before start\(\)/)
|
||||
// First bench installed the proxy; a second loader must refuse.
|
||||
expect(() => createClientLoader({ ctx: new Context(), modules: {}, boot: { plugins: [] } })).toThrow(/already installed/)
|
||||
})
|
||||
|
||||
it('direct load() before a dependency is active fails loud (same check start() sequences)', async () => {
|
||||
const b = bench(
|
||||
[entry('dep', [], true), entry('needy', ['dep'])],
|
||||
{ '/plugins/dep/client.js': okBundle(), '/plugins/needy/client.js': okBundle() },
|
||||
)
|
||||
await expect(b.loader.load('needy')).rejects.toThrow(/loaded before its dependency "dep" is active/)
|
||||
})
|
||||
|
||||
it('direct load() naming an unknown inject target fails loud', async () => {
|
||||
const b = bench([entry('solo', ['phantom'])], { '/plugins/solo/client.js': okBundle() })
|
||||
await expect(b.loader.load('solo')).rejects.toThrow(/injects unknown plugin "phantom"/)
|
||||
})
|
||||
|
||||
it('an immediately-group fetch failure surfaces through settled, not as an unhandled prefetch rejection', async () => {
|
||||
// The fire-and-forget prefetch swallow arm must absorb the early
|
||||
// rejection; the awaited load surfaces the same failure via settled().
|
||||
const ctx = new Context()
|
||||
delete win.DSHClientProxy
|
||||
const loader = createClientLoader({
|
||||
ctx,
|
||||
modules: {},
|
||||
boot: { plugins: [{ id: 'kaboom', url: '/plugins/kaboom/client.js', inject: [], immediately: true }] },
|
||||
fetchBundle: () => Promise.reject(new Error('bundle fetch exploded')),
|
||||
executeBundle: () => {},
|
||||
})
|
||||
loader.start()
|
||||
await expect(loader.settled()).rejects.toThrow(/bundle fetch exploded/)
|
||||
})
|
||||
|
||||
it('unload is the P-I stub', async () => {
|
||||
const b = bench([], {})
|
||||
await expect(b.loader.unload('x')).rejects.toThrow(/not implemented/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DOM default seams (stubbed globals)', () => {
|
||||
it('default fetchBundle uses fetch, rejects non-OK; default executeBundle injects an inline script; claimStyles tags orphans', async () => {
|
||||
const origFetch = globalThis.fetch
|
||||
const appended: { textContent?: string | null }[] = []
|
||||
const styleTag = {
|
||||
attrs: {} as Record<string, string>,
|
||||
setAttribute(k: string, v: string) { this.attrs[k] = v },
|
||||
}
|
||||
const fakeDoc = {
|
||||
createElement: () => {
|
||||
const el = { textContent: null as string | null }
|
||||
return el
|
||||
},
|
||||
head: { appendChild: (el: { textContent?: string | null }) => { appended.push(el) } },
|
||||
querySelectorAll: () => [styleTag],
|
||||
}
|
||||
const g = globalThis as { document?: unknown; fetch: typeof fetch }
|
||||
g.document = fakeDoc
|
||||
g.fetch = (url: URL | RequestInfo) => Promise.resolve(
|
||||
(typeof url === 'string' ? url : url instanceof URL ? url.href : url.url).includes('bad')
|
||||
? new Response('x', { status: 500 })
|
||||
: new Response('window.DSHClientProxy.loadPlugin(globalThis.__seamHandoff)', { status: 200 }),
|
||||
)
|
||||
try {
|
||||
delete win.DSHClientProxy
|
||||
const ctx = new Context()
|
||||
const loader = createClientLoader({
|
||||
ctx,
|
||||
modules: {},
|
||||
boot: { plugins: [
|
||||
{ id: 'seam-ok', url: '/plugins/seam-ok/client.js', inject: [] },
|
||||
{ id: 'seam-bad', url: '/plugins/bad/client.js', inject: [] },
|
||||
] },
|
||||
// NO seams injected (keys omitted, not undefined — exactOptional):
|
||||
// the DOM defaults are under test.
|
||||
})
|
||||
const seamHandoff: ClientPluginHandoff = {
|
||||
id: 'seam-ok',
|
||||
factory: () => ({ apply: () => {} }),
|
||||
}
|
||||
// Default executeBundle only APPENDS the script element (no execution in
|
||||
// our fake DOM), so drive the handoff manually before load resolves it.
|
||||
const loadOk = loader.load('seam-ok')
|
||||
await Promise.resolve()
|
||||
;(globalThis as Win).DSHClientProxy?.loadPlugin(seamHandoff)
|
||||
await loadOk
|
||||
expect(appended).toHaveLength(1)
|
||||
expect(appended[0]?.textContent).toContain('sourceURL=/plugins/seam-ok/client.js')
|
||||
expect(styleTag.attrs['data-plugin']).toBe('seam-ok')
|
||||
await expect(loader.load('seam-bad')).rejects.toThrow(/answered 500/)
|
||||
} finally {
|
||||
g.fetch = origFetch
|
||||
delete (globalThis as { document?: unknown }).document
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('handoff slot protocol', () => {
|
||||
it('rejects an overlapping loadPlugin before the loader claims the pending handoff', () => {
|
||||
delete win.DSHClientProxy
|
||||
createClientLoader({ ctx: new Context(), modules: {}, boot: { plugins: [] } })
|
||||
const proxy = (globalThis as Win).DSHClientProxy
|
||||
proxy?.loadPlugin({ id: 'first', factory: () => ({ apply: () => {} }) })
|
||||
expect(() => proxy?.loadPlugin({ id: 'second', factory: () => ({ apply: () => {} }) }))
|
||||
.toThrow(/overlapping loadPlugin handoff/)
|
||||
})
|
||||
})
|
||||
@@ -20,6 +20,9 @@
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../../host/apiproxy"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
|
||||
@@ -1,23 +1,3 @@
|
||||
import type { UserConfig } from 'tsdown'
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
/**
|
||||
* Standard dual-entry shape plus the loader lib half: exports["./loader"]
|
||||
* promises lib/loader.js (the web shell statically imports the machinery —
|
||||
* a loader cannot load itself), and the shared preset only emits
|
||||
* lib/{index,invariant}.js, so the extra config supplies it.
|
||||
*/
|
||||
const configs = clientBundle('@deepseek-ai/dsh-client-runtime', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
|
||||
const loaderLib: UserConfig = {
|
||||
entry: { loader: 'lib/types/client/loader/index.js' },
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'neutral',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
}
|
||||
|
||||
export default [...configs, loaderLib]
|
||||
export default clientBundle('@deepseek-ai/dsh-client-runtime', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Shared tsdown preset for UI plugin client bundles. Emits a closure-factory
|
||||
* artifact: the bundle calls window.DSHClientProxy.loadPlugin({id, factory})
|
||||
* artifact: the bundle calls window.__ModuleLoader__.load({id, factory})
|
||||
* and resolves externals through the injected require (loader module table —
|
||||
* cordis DI entities, no globals, no import map). CSS Modules are compiled by
|
||||
* lightningcss inside the bundle: importing `x.module.css` yields the
|
||||
@@ -11,6 +11,7 @@ import { readFile } from 'node:fs/promises'
|
||||
import { basename, dirname, resolve as resolvePath } from 'node:path'
|
||||
import type { UserConfig } from 'tsdown'
|
||||
import { transform } from 'lightningcss'
|
||||
import { PLATFORM_MODULES } from './web/src/platform.ts'
|
||||
|
||||
/**
|
||||
* Virtual-id wrapper keeping module CSS away from tsdown's own css pipeline
|
||||
@@ -28,22 +29,20 @@ const CSS_VIRTUAL_SUFFIX = '.mjs'
|
||||
*/
|
||||
export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/
|
||||
|
||||
/** Externals resolved from the loader module table (keep in sync with the shell's seeding list). */
|
||||
export const CLIENT_EXTERNALS = [
|
||||
'react',
|
||||
'react-dom',
|
||||
'react/jsx-runtime',
|
||||
'cordis',
|
||||
'@deepseek-ai/dsh-client-ui-slots',
|
||||
'@deepseek-ai/dsh-client-web-react',
|
||||
'@deepseek-ai/dsh-client-ui-primitives',
|
||||
'@deepseek-ai/dsh-client-connection/client',
|
||||
'@deepseek-ai/dsh-client-runtime/client',
|
||||
'@deepseek-ai/dsh-client-ui-layout/client',
|
||||
'@deepseek-ai/dsh-client-ui-conversation/client',
|
||||
'@deepseek-ai/dsh-client-ui-theme/client',
|
||||
'@deepseek-ai/dsh-client-i18n/client',
|
||||
]
|
||||
/**
|
||||
* Documented TEMPORARY exemption, not a platform module (hence not in
|
||||
* platform.ts): the snapshot-store engine (createSnapshotStore/defineStore/
|
||||
* shallowEqual) lives in runtime pending its promotion-time rehoming, and
|
||||
* five importers (i18n, ui-layout, ui-conversation ×3) ride this single
|
||||
* exemption. At runtime the lazy CJS table answers the require natively:
|
||||
* runtime is an immediately-tier row, its factory is registered before any
|
||||
* dependent bundle materializes. TODO(webload/store-rehome): remove with the
|
||||
* store-engine relocation follow-up.
|
||||
*/
|
||||
const RUNTIME_STORE_EXEMPTION = '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Externals resolved from the loader module table: the platform seed entries plus the documented runtime exemption. */
|
||||
export const CLIENT_EXTERNALS: readonly string[] = [...PLATFORM_MODULES, RUNTIME_STORE_EXEMPTION]
|
||||
|
||||
/**
|
||||
* Build the tsdown config for one UI plugin package: the node-half lib build
|
||||
@@ -51,8 +50,8 @@ export const CLIENT_EXTERNALS = [
|
||||
* the root workspace shape, so the lib half must be restated here — dropping
|
||||
* it leaves the package without lib/index.js and the host Loader cannot
|
||||
* import its node half.
|
||||
* @param id - plugin id (package name), stamped into the loadPlugin handoff
|
||||
* and onto the injected style tags.
|
||||
* @param id - plugin id (package name), stamped into the __ModuleLoader__.load
|
||||
* handoff and onto the injected style tags.
|
||||
* @param libEntry - node-half entries, spelled at the call site so the
|
||||
* package-invariants gate can see `lib/types/invariant.js` in each package's
|
||||
* own tsdown.config.ts (a preset-side glob hides it from the mechanical check).
|
||||
@@ -79,7 +78,7 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
|
||||
// Types ship from lib/types (tsc); dts here would wrap the banner/footer into .d.cts and break parsing.
|
||||
dts: false,
|
||||
clean: false,
|
||||
external: CLIENT_EXTERNALS,
|
||||
external: [...CLIENT_EXTERNALS],
|
||||
// Browser bundles inline node-idiom deps (zustand/immer read
|
||||
// process.env.NODE_ENV; zustand's esm build also probes
|
||||
// import.meta.env.MODE, which a CJS output cannot carry — rolldown flags
|
||||
@@ -102,24 +101,20 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
|
||||
// opinion for table entries (external above wins), bundle everything else.
|
||||
noExternal: (id: string) => (CLIENT_EXTERNALS.includes(id) ? undefined : true),
|
||||
plugins: [{
|
||||
// Bundle purity gate: a bare-name import of a module-table package would
|
||||
// slip past CLIENT_EXTERNALS (which lists the /client form) and INLINE a
|
||||
// second copy of that package — duplicate runtime identity (a second
|
||||
// scope Symbol was tonight's white-screen root cause). Resolve-time is
|
||||
// the earliest, most precise interception: rewrite bare table names to
|
||||
// their /client form (the loader registers both specifiers), and reject
|
||||
// any other @deepseek-ai/* leak that is not an inline-safe wire layer.
|
||||
// Bundle purity gate (build-time mirror of the module-edge rules):
|
||||
// platform seed entries stay external, inline-safe wire layers inline,
|
||||
// and every other @deepseek-ai value import is a build error — a
|
||||
// cross-plugin value import either inlines a duplicate runtime instance
|
||||
// or requires a specifier the frozen module table cannot answer.
|
||||
// Cross-plugin collaboration goes through cordis services instead.
|
||||
name: 'dsh-client-bundle-purity',
|
||||
resolveId(source: string) {
|
||||
if (!source.startsWith('@deepseek-ai/')) return null
|
||||
if (CLIENT_EXTERNALS.includes(source)) return null // external wins
|
||||
if (CLIENT_EXTERNALS.includes(`${source}/client`)) {
|
||||
return { id: `${source}/client`, external: true }
|
||||
}
|
||||
if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins
|
||||
if (INLINE_SAFE.test(source)) return null // wire/type layer: inline is the point
|
||||
throw new Error(
|
||||
`client bundle purity: "${source}" is not in CLIENT_EXTERNALS and not an inline-safe wire layer — `
|
||||
+ 'import the /client form, add it to the module table, or it inlines a duplicate runtime instance',
|
||||
`client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS) and not an inline-safe wire layer — `
|
||||
+ 'cross-plugin value imports are forbidden; collaborate through cordis services (type-only imports are erased and never reach this gate)',
|
||||
)
|
||||
},
|
||||
}, {
|
||||
@@ -158,7 +153,7 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
|
||||
}],
|
||||
outputOptions: {
|
||||
entryFileNames: 'client.js',
|
||||
banner: `window.DSHClientProxy.loadPlugin({ id: ${JSON.stringify(id)}, factory: (require) => {`,
|
||||
banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, factory: (require) => {`,
|
||||
footer: `return module.exports; } });`,
|
||||
intro: 'var module = { exports: {} }; var exports = module.exports;',
|
||||
},
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-i18n",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-layout"
|
||||
],
|
||||
"platform": "web"
|
||||
@@ -34,21 +36,25 @@
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"clsx": "^2.0.0",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
@@ -15,14 +15,10 @@
|
||||
*/
|
||||
import { Service } from 'cordis'
|
||||
import type { Context } from 'cordis'
|
||||
// Value import MUST use the /client subpath: only that specifier is in the
|
||||
// bundle externals (CLIENT_EXTERNALS), so it resolves to the shared runtime
|
||||
// module at load time. A bare-specifier value import gets INLINED as a second
|
||||
// module instance whose private scope-tag Symbol never matches the one
|
||||
// SessionsService tags contexts with — scopeOf then always returns undefined
|
||||
// in the browser while unit tests (single-instance path resolution) stay green.
|
||||
import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { Session, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only imports: a plugin-to-plugin value import is a bundle purity
|
||||
// error, so scope resolution goes through the sessions service (scopeOf
|
||||
// method) instead of the standalone helper.
|
||||
import type { Session, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Scope-addressed conversation service (root singleton, provided as `conversation`). */
|
||||
export class ConversationService extends Service {
|
||||
@@ -83,11 +79,17 @@ export class ConversationService extends Service {
|
||||
|
||||
/** Resolve the caller scope's Session or throw on root contexts. */
|
||||
private scopedSession(op: string): Session {
|
||||
const id = scopeOf(this.ctx)
|
||||
const id = this.scopeId(op)
|
||||
return this.requireSessions().manager.get(id)
|
||||
}
|
||||
|
||||
/** Read the caller's session scope tag via the sessions service; root contexts fail loud. */
|
||||
private scopeId(op: string): SessionId {
|
||||
const id = this.requireSessions().scopeOf(this.ctx)
|
||||
if (id === undefined) {
|
||||
throw new Error(`conversation.${op} requires a session scope — address one via ctx.sessions.scope(id).conversation`)
|
||||
}
|
||||
return this.requireSessions().manager.get(id)
|
||||
return id
|
||||
}
|
||||
|
||||
private requireSessions(): SessionsService {
|
||||
|
||||
@@ -76,6 +76,7 @@ async function bench() {
|
||||
manager: { get: () => sessionFake },
|
||||
scope: (id: SessionId) => mint(id),
|
||||
cell: () => undefined,
|
||||
scopeOf,
|
||||
create: vi.fn(() => Promise.resolve(ROOT)),
|
||||
open: vi.fn(),
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ async function bench(opts?: { sessions?: boolean }) {
|
||||
create: createMock,
|
||||
open: openMock,
|
||||
scope: (id: SessionId) => (id === sid('new-1') ? mint(id) : scopes.get(id)),
|
||||
scopeOf,
|
||||
} as unknown as SessionsService
|
||||
if (opts?.sessions !== false) ctx.provide('sessions', sessionsFake)
|
||||
// Class-plugin mount — the same form apply.ts uses in production.
|
||||
|
||||
@@ -33,19 +33,20 @@
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
@@ -14,8 +14,13 @@ import { QuestionComposer } from './QuestionComposer.tsx'
|
||||
export { PendingQuestion } from './contract/slots.ts'
|
||||
export type { QuestionAnswer, QuestionComposerProps, QuestionWait } from './contract/slots.ts'
|
||||
|
||||
/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */
|
||||
export const inject = ['slots']
|
||||
/**
|
||||
* Required services (cordis fiber inject). 'conversation' is an ordering
|
||||
* edge, not a call dependency: the 'conversation.composer' chain slot is
|
||||
* declared by ui-conversation's apply, and register() into an undeclared
|
||||
* slot throws — service waiting orders this apply after the declaring one.
|
||||
*/
|
||||
export const inject = ['slots', 'conversation']
|
||||
|
||||
/** Chain routing: claim the composer while a question wait is pending (pure — owner props only). */
|
||||
function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | null {
|
||||
|
||||
@@ -23,17 +23,23 @@ async function bench() {
|
||||
{ name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never,
|
||||
() => null,
|
||||
)
|
||||
// 'conversation' inject is an ordering edge (the declaring plugin provides
|
||||
// it after declaring the chain); the bench declares the chain itself.
|
||||
ctx.provide('conversation', {})
|
||||
return { ctx, slots }
|
||||
}
|
||||
|
||||
describe('apply', () => {
|
||||
it('declares the services it binds', () => {
|
||||
expect(inject).toEqual(['slots'])
|
||||
expect(inject).toEqual(['slots', 'conversation'])
|
||||
})
|
||||
|
||||
it('fails loud when no live entry has declared the composer slot', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
// Satisfy the ordering inject without declaring the chain: apply must
|
||||
// then hit the undeclared-slot throw, not sit waiting on the service.
|
||||
ctx.provide('conversation', {})
|
||||
await expect(ctx.plugin({ inject: [...inject], apply }))
|
||||
.rejects.toThrow(/slot "conversation.composer" is not declared/)
|
||||
})
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-layout"
|
||||
],
|
||||
"platform": "web"
|
||||
@@ -34,21 +35,25 @@
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"clsx": "^2.0.0",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
@@ -28,10 +28,6 @@
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
|
||||
@@ -33,19 +33,19 @@
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
@@ -12,8 +12,14 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { TrajectoryView } from './TrajectoryView.tsx'
|
||||
import { WaterfallView } from './WaterfallView.tsx'
|
||||
|
||||
/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */
|
||||
export const inject = ['slots']
|
||||
/**
|
||||
* Required services (cordis fiber inject). 'conversation' is an ordering
|
||||
* edge, not a call dependency: the 'conversation.view' slot is declared by
|
||||
* ui-conversation's apply (which then provides the service), and register()
|
||||
* into an undeclared slot throws — service waiting is what orders this
|
||||
* apply after the declaring one.
|
||||
*/
|
||||
export const inject = ['slots', 'conversation']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the trajectory and waterfall view tabs. The
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Real tsdown artifact shape: lib/client.js hands off through
|
||||
* window.DSHClientProxy.loadPlugin, resolves externals through the injected
|
||||
* window.__ModuleLoader__.load, resolves externals through the injected
|
||||
* require, returns the export surface (apply + inject), and a mounted apply
|
||||
* registers both view tabs into a real SlotsService ring. Skips when dist/ is
|
||||
* not built (`pnpm --filter @deepseek-ai/dsh-client-ui-trajectory bundle`).
|
||||
@@ -15,7 +15,7 @@ import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
const PLUGIN_ID = '@deepseek-ai/dsh-client-ui-trajectory'
|
||||
|
||||
interface Handoff { id: string; factory: (require: (spec: string) => unknown) => Record<string, unknown> }
|
||||
type Win = { DSHClientProxy?: { loadPlugin(h: Handoff): void } }
|
||||
type Win = { __ModuleLoader__?: { load(h: Handoff): void } }
|
||||
|
||||
function readBundle(): string | undefined {
|
||||
try {
|
||||
@@ -28,7 +28,7 @@ function readBundle(): string | undefined {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as Win).DSHClientProxy
|
||||
delete (window as Win).__ModuleLoader__
|
||||
for (const el of document.querySelectorAll('style')) el.remove()
|
||||
})
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('tsdown client artifact', () => {
|
||||
|
||||
async function loadArtifact() {
|
||||
let handoff: Handoff | undefined
|
||||
;(window as Win).DSHClientProxy = { loadPlugin: (h) => { handoff = h } }
|
||||
;(window as Win).__ModuleLoader__ = { load: (h) => { handoff = h } }
|
||||
// Same execution form the loader uses (inline script eval, window scope) —
|
||||
// the implied-eval ban targets accidental string execution, not this
|
||||
// deliberate bundle-execution fixture.
|
||||
@@ -59,7 +59,7 @@ describe('tsdown client artifact', () => {
|
||||
const { handoff, surface } = await loadArtifact()
|
||||
expect(handoff.id).toBe(PLUGIN_ID)
|
||||
expect(surface.apply).toBeTypeOf('function')
|
||||
expect(surface.inject).toEqual(['slots'])
|
||||
expect(surface.inject).toEqual(['slots', 'conversation'])
|
||||
})
|
||||
|
||||
it.skipIf(code === undefined)('mounted as an object plugin, apply registers both view tabs on the real ring', async () => {
|
||||
@@ -71,6 +71,10 @@ describe('tsdown client artifact', () => {
|
||||
name: 'root',
|
||||
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
|
||||
}, (_p: { renderSlot?: unknown }) => null)
|
||||
// The plugin injects 'conversation' as an ordering edge (the declaring
|
||||
// plugin provides it after declaring the ring); the bench declares the
|
||||
// ring itself, so a stub satisfies the wait.
|
||||
ctx.provide('conversation', {})
|
||||
const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void })
|
||||
await fiber.await()
|
||||
expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory', 'waterfall'])
|
||||
|
||||
@@ -83,6 +83,9 @@ async function bench() {
|
||||
const chatBody = vi.fn(() => <div data-testid="chat-body" />)
|
||||
slots.register(
|
||||
{ name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never)
|
||||
// 'conversation' inject is an ordering edge; the bench declares the ring
|
||||
// itself, so a stub satisfies the wait.
|
||||
ctx.provide('conversation', {})
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
return { ctx, slots, fiber }
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
# @deepseek-ai/dsh-client-web
|
||||
|
||||
Web shell library: `bootWebShell(el, seams?)` mounts the whole client — loader machinery (statically held; a loader cannot load itself), pure-library module-table seeding, AppRoot (boot loading page → settled → full UI in one switch), and the SessionProvider/scopedSlots assembly closure. The vite application entry lives in apps/web and only calls `bootWebShell`. Contract: api-contracts v3 §9.3.
|
||||
Web shell kernel: `bootWebShell(el, seams?)` mounts the whole client through the two-stage boot (web2). Stage one (module face): build the client module system (`@deepseek-ai/dsh-client-modules`) over the host-pushed entry graph (`window.__DSH_BOOT__`) and prefetch the `immediately` tier in parallel — bundle execution registers factories only. Stage two (plugin face): mount the vendored cordis Loader with the module system injected as its `internal` seam, create one loader entry per graph row plus the shell-own app-shell assembly entry (tree.import materializes each module), and gate AppRoot on the settle (loader quiesced + every entry fiber ACTIVE → full UI in one switch). Composition is entirely the host graph's: the roster and the immediately tier live in the composing app; the shell makes zero composition decisions.
|
||||
|
||||
The optional `seams` parameter forwards the client loader's `fetchBundle`/`executeBundle` transport overrides (`BootSeams`); production callers omit it — it exists for test environments where `<script>` execution cannot reach the page context (jsdom).
|
||||
Shell self-sufficiency (web2 hard rule): the kernel value-imports no plugin package — the boot status store and signals are hand-rolled here (`loader-status.ts`), so the loading page works while (and especially when) plugins fail. The app-shell assembly (`@deepseek-ai/dsh-client-app-shell`, a shell-owned pseudo entry with no npm package behind it) is the only module registered through `registerStatic`; it inject-waits on slots/sessions/layout like any plugin.
|
||||
|
||||
`PLATFORM_MODULES` (src/platform.ts) is the single source of truth for the shared module surface: seed-table keys, tsdown client externals, and the vite alias set are its projections.
|
||||
|
||||
The optional `seams` parameter forwards the module system's `fetchBundle`/`executeBundle` transport overrides (`BootSeams`); production callers omit it — it exists for test environments where `<script>` execution cannot reach the page context (jsdom).
|
||||
|
||||
The shell owns browser-title projection. With a selected session carrying a durable title, it renders `<session title> — <existing HTML title>` and reacts to later title revisions; no selection or a selected untitled session preserves the existing title, and shell unmount restores it. The existing HTML title remains the configurable product suffix.
|
||||
|
||||
@@ -16,6 +20,5 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **One-shot rendering by design** — the UI waits for `loader.settled()`; a single plugin failure keeps the loading page with a loud error, no partial availability (progressive rendering returns with its own project).
|
||||
- **No HMR** — the dev loop is tsdown watch + manual refresh for plugins; vite serves only the shell.
|
||||
- **One-shot rendering by design** — the UI waits for the boot settle; a single entry failure keeps the loading page with a loud per-entry report, no partial availability (progressive rendering returns with its own project).
|
||||
- **Narrow-window acceptance is deferred** — the concession chain is implemented in ui-layout but the shell-level narrow-viewport walkthrough is a P-II acceptance item.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-web",
|
||||
"description": "Web shell library: bootWebShell (loader holding + module-table seeding + AppRoot gate + plugin assembly), consumed by the apps/web vite entry",
|
||||
"description": "Web shell kernel: bootWebShell (module system holding + seed table + two-stage boot + AppRoot gate + app-shell assembly entry), consumed by the apps/web vite entry",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -20,8 +20,7 @@
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-modules": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
|
||||
@@ -30,6 +29,8 @@
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"@types/react-dom": "~18.3.0",
|
||||
@@ -37,6 +38,7 @@
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
|
||||
@@ -1,39 +1,46 @@
|
||||
/**
|
||||
* Shell root: boot loading page → (loader settled) → real UI in one switch.
|
||||
* Pure shell component with zero plugin dependencies — before settled it may
|
||||
* only rely on itself; the real UI is produced by the boot assembly closure
|
||||
* (renderApp) once every plugin is active. A failed plugin keeps the loading
|
||||
* page and lists the failures (fail loud, no partial UI).
|
||||
* Shell root: boot loading page → (boot settled) → real UI in one switch.
|
||||
* Pure kernel component with zero plugin dependencies — before settled it may
|
||||
* only rely on itself (the fail-loud presentation must not depend on the
|
||||
* system whose failure it reports; the status/signal stores are kernel-own,
|
||||
* web2 shell self-sufficiency rule); the real UI is produced by the
|
||||
* app-shell entry once every entry is active. A failed boot keeps the
|
||||
* loading page, lists the per-entry fiber states and the sweep report (fail
|
||||
* loud, no partial UI).
|
||||
*/
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { LoaderStatus } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { KernelSignal, LoaderStatus } from './loader-status.ts'
|
||||
import css from './AppRoot.module.css'
|
||||
|
||||
/** AppRoot props: settled signal, loader status feed, deferred real-UI factory. */
|
||||
/** AppRoot props: settled signal, fiber-state projection feed, boot failure report, deferred real-UI factory. */
|
||||
export interface AppRootProps {
|
||||
/** True once loader.settled() resolved (the boot closure flips it; status-derived guesses race an incrementally filled table). */
|
||||
settled: ObservableSnapshot<boolean>
|
||||
/** Loader per-plugin status store (drives loading/failed rendering). */
|
||||
status: SnapshotStore<LoaderStatus>
|
||||
/** True once the boot chain settled (loader quiesced + all entries ACTIVE); the boot closure flips it. */
|
||||
settled: KernelSignal<boolean>
|
||||
/** Per-entry fiber-state projection store (drives loading/failed rendering). */
|
||||
status: KernelSignal<LoaderStatus>
|
||||
/** Boot failure report (the settle rejection message); undefined while loading or after success. */
|
||||
error: KernelSignal<string | undefined>
|
||||
/** Builds the real UI; called only after settled. */
|
||||
renderApp: () => ReactNode
|
||||
}
|
||||
|
||||
/** Boot gate: loading page until the loader settles; failures stay here. */
|
||||
/** Boot gate: loading page until the boot settles; failures stay here. */
|
||||
export function AppRoot(props: AppRootProps) {
|
||||
const settled = useSyncExternalStore(props.settled.subscribe, props.settled.getSnapshot)
|
||||
const status = useSyncExternalStore(props.status.subscribe, props.status.getSnapshot)
|
||||
const error = useSyncExternalStore(props.error.subscribe, props.error.getSnapshot)
|
||||
const failed = Object.entries(status).filter(([, s]) => s === 'failed')
|
||||
|
||||
if (settled) return <>{props.renderApp()}</>
|
||||
|
||||
const loud = error !== undefined || failed.length > 0
|
||||
|
||||
return (
|
||||
<div className={css.boot}>
|
||||
<div className={css.card}>
|
||||
<div className={css.wordmark}>HARNESS</div>
|
||||
{failed.length === 0
|
||||
{!loud
|
||||
? (
|
||||
<>
|
||||
<div className={css.spinner} />
|
||||
@@ -44,6 +51,7 @@ export function AppRoot(props: AppRootProps) {
|
||||
<div className={css.failed}>
|
||||
<div className={css.failedTitle}>Failed to load plugins</div>
|
||||
{failed.map(([id]) => <div key={id} className={css.failedItem}>{id}</div>)}
|
||||
{error !== undefined && <div className={css.failedItem}>{error}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* App-shell assembly plugin (design §3.4): the shell's ONLY composition
|
||||
* responsibility, packaged as a normal static-arrival entry so the host graph
|
||||
* stays the single composition authority. It rides the same entry lifecycle
|
||||
* as every other plugin — the fiber waits on slots/sessions/layout, so by the
|
||||
* time apply runs the layout entry is mounted and its export surface is
|
||||
* readable from the governance side (module loadCache, design §2.6).
|
||||
*
|
||||
* The pseudo package id exists only in the host graph and the shell's static
|
||||
* registry; there is no npm package behind it.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Context } from 'cordis'
|
||||
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { buildRenderApp } from './app.tsx'
|
||||
|
||||
/** Shell-owned pseudo entry id under which the host graph mounts this plugin. */
|
||||
export const APP_SHELL_ID = '@deepseek-ai/dsh-client-app-shell'
|
||||
|
||||
/** The assembled-UI face AppRoot renders once the boot settles. */
|
||||
export interface AppShellService {
|
||||
/** Build (once) and render the real UI tree. */
|
||||
renderApp: () => ReactNode
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** The shell assembly face, provided by the app-shell entry once its inject set is active. */
|
||||
appShell: AppShellService
|
||||
}
|
||||
}
|
||||
|
||||
/** Cordis plugin name. */
|
||||
export const name = 'app-shell'
|
||||
|
||||
/** Required services: the product services the assembly closes over (layout registers the 'root' slot entry). */
|
||||
export const inject = ['slots', 'sessions', 'layout']
|
||||
|
||||
/**
|
||||
* Plugin body: install the React renderer into the slot system and provide
|
||||
* the renderApp face (one ctx-level renderSlot('root') call).
|
||||
* @param ctx - plugin context (inject set active).
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
// The renderer install is shell territory (web-react is shell-bundled),
|
||||
// but ctx.slots exists only once the runtime entry is active — so it lands
|
||||
// here, on the entry whose inject set guarantees that ordering.
|
||||
ctx.slots.install(createSlotRenderer())
|
||||
|
||||
// Assemble once on first render: the closure must be identity-stable
|
||||
// across AppRoot re-renders.
|
||||
let renderApp: (() => ReactNode) | undefined
|
||||
ctx.reflect.provide('appShell', {
|
||||
renderApp: (): ReactNode => {
|
||||
renderApp ??= buildRenderApp({ ctx })
|
||||
return renderApp()
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Real-UI assembly closure. Runs only after loader.settled(): the whole
|
||||
* layout tree hangs off the built-in 'root' slot (ui-layout registers
|
||||
* AppFrame there and renders the child slots internally) — the shell's
|
||||
* render is the one ctx-level renderSlot call in the program.
|
||||
* Real-UI assembly closure, invoked by the app-shell plugin once its inject
|
||||
* set is active: the whole layout tree hangs off the built-in 'root' slot
|
||||
* (ui-layout registers AppFrame there and renders the child slots
|
||||
* internally) — the shell's render is the one ctx-level renderSlot call in
|
||||
* the program.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Context } from 'cordis'
|
||||
@@ -12,16 +13,14 @@ import { DocumentTitle } from './DocumentTitle.tsx'
|
||||
// Type-only: pulls the runtime's SlotMap declaration merge (the 'root' key) into this program.
|
||||
import type {} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Assembly inputs: the settled root ctx plus the loader's module-table read surface. */
|
||||
/** Assembly inputs: the active app-shell plugin ctx (slots/sessions/layout services provided). */
|
||||
export interface AssemblyDeps {
|
||||
/** Client root context (all plugin services provided). */
|
||||
/** Client context with the assembly's inject set active. */
|
||||
ctx: Context
|
||||
/** Module-table resolver (the loader's require; missing spec = throw). Kept in the seam for future shell needs. */
|
||||
requireModule: (spec: string) => unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the renderApp factory handed to AppRoot.
|
||||
* Build the renderApp factory the app-shell plugin provides to AppRoot.
|
||||
* @param deps - assembly inputs.
|
||||
* @returns factory producing the real UI tree (called once per AppRoot render after settled).
|
||||
*/
|
||||
|
||||
@@ -1,73 +1,172 @@
|
||||
/**
|
||||
* Web shell boot — the library face consumed by the apps/web entry (api
|
||||
* contracts v3 §0.3/§9.3): root ctx → hold the loader machinery (statically
|
||||
* imported; the loader cannot load itself) → seed the module table → render
|
||||
* the AppRoot loading page → loader.start() → await settled() → flip the
|
||||
* settled signal so AppRoot switches to the real UI in one pass. Load
|
||||
* failures reject settled(); AppRoot stays on the loading page listing them
|
||||
* (fail loud).
|
||||
* Web shell boot — the kernel face consumed by the apps/web entry. Everything
|
||||
* here is machinery that cannot itself be an entry, and none of it
|
||||
* value-imports a plugin package (web2 shell self-sufficiency rule: the
|
||||
* loading page must work while — especially when — plugins fail).
|
||||
*
|
||||
* Two-stage boot (web2 §0):
|
||||
* Stage one (module face): build the module system over the host graph
|
||||
* (`window.__DSH_BOOT__`) and prefetch every `immediately` row in parallel
|
||||
* — fetch + execute registers factories only; module side effects wait for
|
||||
* materialization. Prefetch failures are non-fatal here: stage two's
|
||||
* import path retries the fetch and owns the loud failure.
|
||||
* Stage two (plugin face): mount the vendored cordis Loader, inject the
|
||||
* module system as its internal seam (BEFORE any entry exists — the
|
||||
* bare-import fallback in tree.import must never run in a browser), create
|
||||
* one loader entry per graph row (tree.import materializes each module),
|
||||
* let fibers activate on service availability, then loader.await() + a
|
||||
* full fiber sweep (all ACTIVE, else reject listing who/what/which
|
||||
* service) → flip the settled signal so AppRoot switches to the real UI in
|
||||
* one pass.
|
||||
*
|
||||
* Composition lives in the host graph; the shell makes zero composition
|
||||
* decisions (the app-shell assembly is itself a graph entry, the only
|
||||
* shell-own module registered with the module system).
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createClientLoader, type ClientLoaderOptions } from '@deepseek-ai/dsh-client-runtime/loader'
|
||||
import {
|
||||
createClientModuleLoader,
|
||||
type ClientModuleLoader, type ClientModuleLoaderOptions, type DshWindow, type WebBootGraph,
|
||||
} from '@deepseek-ai/dsh-client-modules'
|
||||
import * as AppShell from './app-shell.ts'
|
||||
import { APP_SHELL_ID } from './app-shell.ts'
|
||||
import { AppRoot } from './AppRoot.tsx'
|
||||
import { buildRenderApp } from './app.tsx'
|
||||
import { seedModules } from './seed.ts'
|
||||
import { getStaticModules } from './seed.ts'
|
||||
import {
|
||||
STATE_LABELS, createLoaderStatusStore, createSignal, type LoaderStatusStore,
|
||||
} from './loader-status.ts'
|
||||
import './base.css'
|
||||
|
||||
/** Manually flipped settled signal (AppRoot's gate; see AppRootProps.settled). */
|
||||
function settledSignal(): ObservableSnapshot<boolean> & { flip: () => void } {
|
||||
let value = false
|
||||
const listeners = new Set<() => void>()
|
||||
return {
|
||||
getSnapshot: () => value,
|
||||
subscribe: (fn) => { listeners.add(fn); return () => { listeners.delete(fn) } },
|
||||
flip: () => {
|
||||
value = true
|
||||
for (const fn of [...listeners]) fn()
|
||||
},
|
||||
/** Module transport seams the shell passes through (jsdom tests replace the <script> path). */
|
||||
export type BootSeams = Pick<ClientModuleLoaderOptions, 'fetchBundle' | 'executeBundle'>
|
||||
|
||||
/**
|
||||
* Sweep every loader entry after the tree quiesced: an entry without a fiber
|
||||
* failed its import; a fiber not ACTIVE is FAILED (apply threw) or PENDING
|
||||
* (a required service never arrived — cordis inject waiting has no timeout,
|
||||
* so this sweep is the fail-loud compensation).
|
||||
*/
|
||||
function assertEntriesActive(ctx: Context): void {
|
||||
const failures: string[] = []
|
||||
for (const entry of ctx.loader.entries()) {
|
||||
const name = entry.options.name
|
||||
if (entry.fiber === undefined) {
|
||||
failures.push(`${name}: import failed (see console for the import error)`)
|
||||
continue
|
||||
}
|
||||
const state = STATE_LABELS[entry.fiber.state]
|
||||
if (state === 'active') continue
|
||||
if (state === 'pending') {
|
||||
const missing = Object.keys(entry.fiber.inject).filter((service) => ctx.get(service) === undefined)
|
||||
failures.push(`${name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
|
||||
} else {
|
||||
failures.push(`${name}: ${state}`)
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new Error(`web boot: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Loader transport seams the shell passes through (jsdom tests replace the <script> path). */
|
||||
export type BootSeams = Pick<ClientLoaderOptions, 'fetchBundle' | 'executeBundle'>
|
||||
/** Stage one: prefetch the immediately tier (factory registration only; failures defer to stage two's import). */
|
||||
async function prefetchImmediateTier(modules: ClientModuleLoader, graph: WebBootGraph): Promise<void> {
|
||||
await Promise.all(graph.entries
|
||||
.filter((row) => row.immediately === true)
|
||||
.map((row) => modules.prefetch(row.id).catch(() => {
|
||||
// Import (stage two) refetches and reports this loudly per entry;
|
||||
// swallowing here keeps one failing prefetch from masking the others.
|
||||
})))
|
||||
}
|
||||
|
||||
/** Stage two: mount the Loader, inject the internal seam, create the graph entries, settle, sweep. */
|
||||
async function runPluginBoot(
|
||||
ctx: Context, modules: ClientModuleLoader, graph: WebBootGraph, status: LoaderStatusStore,
|
||||
): Promise<void> {
|
||||
await ctx.plugin(Loader)
|
||||
const loader = ctx.loader
|
||||
// Inject the module system BEFORE any entry exists: tree.import falls back
|
||||
// to a bare dynamic import when internal is undefined, which in a browser
|
||||
// is a guaranteed loud failure — correct as a tripwire, never as a path.
|
||||
loader.internal = modules as never
|
||||
|
||||
// Status projection: AppRoot displays fiber truth. Every internal/status
|
||||
// transition under an entry re-projects that entry's row from its ROOT
|
||||
// fiber (child plugin fibers share the same entry).
|
||||
ctx.on('internal/status', (fiber) => {
|
||||
const entry = fiber.entry
|
||||
if (entry === undefined || entry.fiber === undefined) return
|
||||
status.set(entry.options.name, STATE_LABELS[entry.fiber.state])
|
||||
})
|
||||
|
||||
// Entry creation order carries no semantics (fiber inject waiting owns
|
||||
// activation order); creating concurrently lets non-prefetched bundle
|
||||
// fetches parallelize. The app-shell assembly entry is appended by the
|
||||
// kernel: it is shell-own code (host graph rows are all plugin bundles),
|
||||
// and mounting the assembly is not a composition decision — it rides the
|
||||
// same entry lifecycle so the sweep and status cover it uniformly.
|
||||
const rows = [...graph.entries.map((row) => row.id), APP_SHELL_ID]
|
||||
await Promise.all(rows.map(async (name) => {
|
||||
status.set(name, 'loading')
|
||||
const id = await loader.create({ name })
|
||||
// A failed import leaves the entry fiberless (Entry._init logs and
|
||||
// returns); project it as failed — no fiber means no status event.
|
||||
if (loader.resolve(id).fiber === undefined) {
|
||||
status.set(name, 'failed')
|
||||
}
|
||||
}))
|
||||
|
||||
await loader.await()
|
||||
assertEntriesActive(ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the web shell into a DOM element and start the plugin load chain.
|
||||
* Mount the web shell into a DOM element and start the two-stage boot chain.
|
||||
* @param el - mount point (the app's #root).
|
||||
* @param seams - optional loader transport overrides (test environments).
|
||||
* @param seams - optional module transport overrides (test environments).
|
||||
* @returns unmount disposer.
|
||||
*/
|
||||
export function bootWebShell(el: HTMLElement, seams?: BootSeams): () => void {
|
||||
const ctx = new Context()
|
||||
const loader = createClientLoader({ ctx, modules: seedModules(), ...seams })
|
||||
ctx.reflect.provide('loader', loader)
|
||||
const graph = (globalThis as DshWindow).__DSH_BOOT__
|
||||
if (graph === undefined) throw new Error('web boot: no entry graph (window.__DSH_BOOT__ missing)')
|
||||
|
||||
const settled = settledSignal()
|
||||
// Assemble once on first post-settled render: SessionProvider and the slot
|
||||
// closures must be identity-stable across re-renders.
|
||||
let renderApp: (() => ReactNode) | undefined
|
||||
const renderAppOnce = (): ReactNode => {
|
||||
renderApp ??= buildRenderApp({ ctx, requireModule: (spec) => loader.requireModule(spec) })
|
||||
return renderApp()
|
||||
}
|
||||
const ctx = new Context()
|
||||
const modules = createClientModuleLoader({ graph, staticModules: getStaticModules(), ...seams })
|
||||
// The app-shell assembly is the only shell-own module: every other graph
|
||||
// row is a plugin bundle arriving through fetch (web2 single package form).
|
||||
modules.registerStatic(APP_SHELL_ID, AppShell)
|
||||
// Contract C5: the module system is a boot-owned kernel service (ctx.modules).
|
||||
ctx.reflect.provide('modules', modules)
|
||||
|
||||
const status = createLoaderStatusStore()
|
||||
const settled = createSignal(false)
|
||||
const error = createSignal<string | undefined>(undefined)
|
||||
|
||||
const root = createRoot(el)
|
||||
root.render(<AppRoot settled={settled} status={loader.status} renderApp={renderAppOnce} />)
|
||||
|
||||
loader.start()
|
||||
loader.settled().then(
|
||||
() => {
|
||||
// The renderer install is a shell-boot act, but ctx.slots exists only
|
||||
// once the runtime plugin loaded — so it lands here, after settled and
|
||||
// before the flip that lets renderApp call renderSlot('root').
|
||||
ctx.slots.install(createSlotRenderer())
|
||||
settled.flip()
|
||||
},
|
||||
() => { /* stay on the loading page; failures render from loader.status */ },
|
||||
root.render(
|
||||
<AppRoot
|
||||
settled={settled}
|
||||
status={status}
|
||||
error={error}
|
||||
renderApp={() => {
|
||||
const shell = ctx.get('appShell')
|
||||
// Unreachable after a clean settle (the app-shell entry is in every graph).
|
||||
if (shell === undefined) throw new Error('web boot: appShell service missing after settled')
|
||||
return shell.renderApp()
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
prefetchImmediateTier(modules, graph)
|
||||
.then(() => runPluginBoot(ctx, modules, graph, status))
|
||||
.then(
|
||||
() => { settled.set(true) },
|
||||
(reason: unknown) => {
|
||||
// Stay on the loading page; surface the sweep report (fail loud).
|
||||
console.error(reason)
|
||||
error.set(reason instanceof Error ? reason.message : String(reason))
|
||||
},
|
||||
)
|
||||
return () => { root.unmount() }
|
||||
}
|
||||
@@ -1,12 +1,20 @@
|
||||
/**
|
||||
* Web shell library entry. The shell's product is {@link bootWebShell} —
|
||||
* apps/web's vite entry calls it against #root; everything else (AppRoot
|
||||
* gate, assembly closure, module-table seed) is internal to the boot chain.
|
||||
* gate, app-shell assembly entry, module-table staticModules, platform constants) is
|
||||
* internal to the boot chain. PLATFORM_MODULES is re-exported as the C1
|
||||
* single source of truth for the tsdown client externals projection.
|
||||
* @module @deepseek-ai/dsh-client-web
|
||||
*/
|
||||
|
||||
export { bootWebShell } from './boot.tsx'
|
||||
export { bootWebShell, type BootSeams } from './boot.tsx'
|
||||
export { AppRoot, type AppRootProps } from './AppRoot.tsx'
|
||||
export { buildRenderApp, type AssemblyDeps } from './app.tsx'
|
||||
export { DocumentTitle, type DocumentTitleProps } from './DocumentTitle.tsx'
|
||||
export { seedModules } from './seed.ts'
|
||||
export { APP_SHELL_ID, type AppShellService } from './app-shell.ts'
|
||||
export { getStaticModules } from './seed.ts'
|
||||
export { PLATFORM_MODULES, type PlatformModule } from './platform.ts'
|
||||
export {
|
||||
STATE_LABELS, FIBER_STATE, createSignal, createLoaderStatusStore,
|
||||
type LoaderStatus, type LoaderEntryState, type KernelSignal, type KernelValueSignal, type LoaderStatusStore,
|
||||
} from './loader-status.ts'
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Fiber-state projection vocabulary and the kernel-owned status store for the
|
||||
* boot loading page. The status AppRoot renders is a projection of the real
|
||||
* cordis fiber states (display the truth, not a retelling) — the boot chain
|
||||
* subscribes `internal/status` and recomputes one row per loader entry.
|
||||
*
|
||||
* The store is hand-rolled here because of the shell self-sufficiency rule
|
||||
* (web2 §0): the snapshot-store machinery lives in the runtime PLUGIN
|
||||
* package, and the shell kernel must not value-import any plugin package —
|
||||
* the loading page has to work while (and especially when) plugins fail.
|
||||
* @module @deepseek-ai/dsh-client-web/src/loader-status
|
||||
*/
|
||||
import type { FiberState } from 'cordis'
|
||||
|
||||
/**
|
||||
* Value mirror of cordis's `FiberState` const enum: a const enum has no
|
||||
* runtime object to import (and esbuild-based pipelines cannot inline it
|
||||
* across modules), so these values mirror the pinned vendored definition
|
||||
* while retaining its type (same rationale as dsh-tool-cordis's mirror).
|
||||
*/
|
||||
export const FIBER_STATE = {
|
||||
PENDING: 0 as FiberState.PENDING,
|
||||
LOADING: 1 as FiberState.LOADING,
|
||||
ACTIVE: 2 as FiberState.ACTIVE,
|
||||
FAILED: 3 as FiberState.FAILED,
|
||||
DISPOSED: 4 as FiberState.DISPOSED,
|
||||
UNLOADING: 5 as FiberState.UNLOADING,
|
||||
} as const
|
||||
|
||||
/** One entry's projected state label (lower-case face of {@link FiberState}). */
|
||||
export type LoaderEntryState = 'pending' | 'loading' | 'active' | 'failed' | 'disposed' | 'unloading'
|
||||
|
||||
/** Label for each fiber state, keyed by member (inlining-safe — no reverse mapping). */
|
||||
export const STATE_LABELS: Record<FiberState, LoaderEntryState> = {
|
||||
[FIBER_STATE.PENDING]: 'pending',
|
||||
[FIBER_STATE.LOADING]: 'loading',
|
||||
[FIBER_STATE.ACTIVE]: 'active',
|
||||
[FIBER_STATE.FAILED]: 'failed',
|
||||
[FIBER_STATE.DISPOSED]: 'disposed',
|
||||
[FIBER_STATE.UNLOADING]: 'unloading',
|
||||
}
|
||||
|
||||
/** Per-entry state projection (AppRoot's status feed), keyed by entry name. */
|
||||
export type LoaderStatus = Record<string, LoaderEntryState>
|
||||
|
||||
/** Minimal observable snapshot the kernel components consume (useSyncExternalStore shape). */
|
||||
export interface KernelSignal<T> {
|
||||
/** Current value (stable reference between changes). */
|
||||
getSnapshot(): T
|
||||
/**
|
||||
* Subscribe to changes.
|
||||
* @param fn - change listener.
|
||||
* @returns the unsubscribe disposer.
|
||||
*/
|
||||
subscribe(fn: () => void): () => void
|
||||
}
|
||||
|
||||
/** Writable one-value signal (settled flag, boot failure report). */
|
||||
export interface KernelValueSignal<T> extends KernelSignal<T> {
|
||||
/**
|
||||
* Publish a new value and notify subscribers.
|
||||
* @param next - the new value.
|
||||
*/
|
||||
set(next: T): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a writable kernel signal.
|
||||
* @param init - initial value.
|
||||
* @returns the signal.
|
||||
*/
|
||||
export function createSignal<T>(init: T): KernelValueSignal<T> {
|
||||
let value = init
|
||||
const listeners = new Set<() => void>()
|
||||
return {
|
||||
getSnapshot: () => value,
|
||||
subscribe: (fn) => { listeners.add(fn); return () => { listeners.delete(fn) } },
|
||||
set: (next) => {
|
||||
value = next
|
||||
for (const fn of [...listeners]) fn()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** The boot status store: per-entry rows over a {@link KernelSignal} face. */
|
||||
export interface LoaderStatusStore extends KernelSignal<LoaderStatus> {
|
||||
/**
|
||||
* Project one entry's state (copy-on-write so getSnapshot references only
|
||||
* change on writes — useSyncExternalStore contract).
|
||||
* @param id - entry name.
|
||||
* @param state - projected fiber state.
|
||||
*/
|
||||
set(id: string, state: LoaderEntryState): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the boot status store.
|
||||
* @returns the store (empty until the boot chain projects rows).
|
||||
*/
|
||||
export function createLoaderStatusStore(): LoaderStatusStore {
|
||||
let value: LoaderStatus = {}
|
||||
const listeners = new Set<() => void>()
|
||||
return {
|
||||
getSnapshot: () => value,
|
||||
subscribe: (fn) => { listeners.add(fn); return () => { listeners.delete(fn) } },
|
||||
set: (id, state) => {
|
||||
value = { ...value, [id]: state }
|
||||
for (const fn of [...listeners]) fn()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Platform singletons the shell shares into the module table.
|
||||
* Single source of truth (design §3.3, contract C1): seed keys = tsdown
|
||||
* client externals = the shared surface. The three projections import this
|
||||
* module — the seed table ({@link ../seed.ts}), the tsdown client preset's
|
||||
* external judgement (packages/client/tsdown.client.ts), and the vite alias
|
||||
* check — so the list cannot drift between them.
|
||||
* @module @deepseek-ai/dsh-client-web/src/platform
|
||||
*/
|
||||
|
||||
/** The module specifiers the shell shares into the frozen module table. */
|
||||
export const PLATFORM_MODULES = [
|
||||
'react', 'react/jsx-runtime', 'react-dom', 'react-dom/client', 'cordis',
|
||||
'@deepseek-ai/dsh-client-ui-slots',
|
||||
'@deepseek-ai/dsh-client-web-react',
|
||||
'@deepseek-ai/dsh-client-ui-primitives',
|
||||
] as const
|
||||
|
||||
/** One platform module specifier (a seed-table key). */
|
||||
export type PlatformModule = (typeof PLATFORM_MODULES)[number]
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* Pure-library module-table seed. These are the ONLY entities statically
|
||||
* built into the shell bundle besides the loader machinery — every plugin
|
||||
* (including the infrastructure four) arrives as a dynamic bundle and
|
||||
* resolves its externals against this table through the loader's require.
|
||||
* Keys must match the tsdown client preset's external specifiers
|
||||
* (packages/client/tsdown.client.ts CLIENT_EXTERNALS ∩ pure libraries).
|
||||
* Platform-singleton module-table. These are the ONLY entities the shell
|
||||
* shares into the frozen module table — fetch bundles resolve their externals
|
||||
* against exactly this set through the loader's require. Keys come from the
|
||||
* platform constant module ({@link ./platform.ts}, contract C1: single source
|
||||
* of truth with the tsdown client externals); values stay shell-static
|
||||
* imports so every bundle sees the same instance.
|
||||
*/
|
||||
import * as React from 'react'
|
||||
import * as ReactJsxRuntime from 'react/jsx-runtime'
|
||||
@@ -14,12 +14,16 @@ import * as Cordis from 'cordis'
|
||||
import * as UiSlots from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import * as WebReact from '@deepseek-ai/dsh-client-web-react'
|
||||
import * as UiPrimitives from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PlatformModule } from './platform.ts'
|
||||
|
||||
/**
|
||||
* Build the seed table handed to the loader machinery at boot.
|
||||
* @returns module specifier → export-surface entity.
|
||||
* Build the static table handed to the module loader at boot.
|
||||
* @returns module specifier → export-surface entity (one entry per platform word).
|
||||
*/
|
||||
export function seedModules(): Record<string, unknown> {
|
||||
export function getStaticModules(): Record<string, unknown> {
|
||||
// The satisfies pin is the projection contract: a word added to
|
||||
// PLATFORM_MODULES without a static import here (or vice versa) fails to
|
||||
// compile instead of drifting into a runtime require miss.
|
||||
return {
|
||||
'react': React,
|
||||
'react/jsx-runtime': ReactJsxRuntime,
|
||||
@@ -29,5 +33,5 @@ export function seedModules(): Record<string, unknown> {
|
||||
'@deepseek-ai/dsh-client-ui-slots': UiSlots,
|
||||
'@deepseek-ai/dsh-client-web-react': WebReact,
|
||||
'@deepseek-ai/dsh-client-ui-primitives': UiPrimitives,
|
||||
}
|
||||
} satisfies Record<PlatformModule, unknown>
|
||||
}
|
||||
@@ -1,42 +1,33 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* AppRoot boot-gate smoke: loading page until the settled signal flips (status
|
||||
* alone never opens the gate), fail-loud plugin list, one-pass switch to the
|
||||
* real UI. The full browser chain (real loader + bundles) is the e2e's job;
|
||||
* this pins the shell-owned gate semantics.
|
||||
* alone never opens the gate), fail-loud entry list + boot failure report,
|
||||
* one-pass switch to the real UI. The full browser chain (real module system
|
||||
* + vendored Loader + bundles) is the e2e's job; this pins the shell-owned
|
||||
* gate semantics. Stores are the kernel-own signals production boot uses
|
||||
* (shell self-sufficiency: the loading page depends on no plugin package).
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
|
||||
afterEach(cleanup)
|
||||
// The snapshot-store engine lives with runtime now; the status-store stub
|
||||
// uses the same channel production code does.
|
||||
import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { LoaderStatus } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { AppRoot } from '@deepseek-ai/dsh-client-web/src/AppRoot.tsx'
|
||||
|
||||
function signal(): ObservableSnapshot<boolean> & { flip: () => void } {
|
||||
let value = false
|
||||
const listeners = new Set<() => void>()
|
||||
return {
|
||||
getSnapshot: () => value,
|
||||
subscribe: (fn) => { listeners.add(fn); return () => { listeners.delete(fn) } },
|
||||
flip: () => { value = true; for (const fn of [...listeners]) fn() },
|
||||
}
|
||||
}
|
||||
import { createLoaderStatusStore, createSignal } from '@deepseek-ai/dsh-client-web/src/loader-status.ts'
|
||||
|
||||
function mount() {
|
||||
const settled = signal()
|
||||
const status = createSnapshotStore<LoaderStatus>({})
|
||||
const settled = createSignal(false)
|
||||
const error = createSignal<string | undefined>(undefined)
|
||||
const status = createLoaderStatusStore()
|
||||
let renders = 0
|
||||
const utils = render(
|
||||
<AppRoot
|
||||
settled={settled}
|
||||
status={status}
|
||||
error={error}
|
||||
renderApp={() => { renders += 1; return <div data-testid="real-ui" /> }}
|
||||
/>,
|
||||
)
|
||||
return { settled, status, counts: () => renders, ...utils }
|
||||
return { settled, status, error, counts: () => renders, ...utils }
|
||||
}
|
||||
|
||||
describe('AppRoot', () => {
|
||||
@@ -50,24 +41,34 @@ describe('AppRoot', () => {
|
||||
it('all-active status alone does not open the gate (settled signal is the only key)', () => {
|
||||
const { status, queryByTestId } = mount()
|
||||
act(() => {
|
||||
status.update((d) => { d['a'] = 'active'; d['b'] = 'active' })
|
||||
status.set('a', 'active')
|
||||
status.set('b', 'active')
|
||||
})
|
||||
expect(queryByTestId('real-ui')).toBeNull()
|
||||
})
|
||||
|
||||
it('lists failed plugins and stays on the loading page', () => {
|
||||
it('lists failed entries and stays on the loading page', () => {
|
||||
const { status, getByText, queryByTestId } = mount()
|
||||
act(() => {
|
||||
status.update((d) => { d['@deepseek-ai/dsh-client-ui-theme'] = 'failed'; d['ok'] = 'active' })
|
||||
status.set('@deepseek-ai/dsh-client-ui-layout', 'failed')
|
||||
status.set('ok', 'active')
|
||||
})
|
||||
expect(getByText('Failed to load plugins')).toBeTruthy()
|
||||
expect(getByText('@deepseek-ai/dsh-client-ui-theme')).toBeTruthy()
|
||||
expect(getByText('@deepseek-ai/dsh-client-ui-layout')).toBeTruthy()
|
||||
expect(queryByTestId('real-ui')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the boot failure report even when no entry projected failed', () => {
|
||||
const { error, getByText, queryByTestId } = mount()
|
||||
act(() => { error.set('web boot: 1 entry did not activate\nx: pending (waiting for service: y)') })
|
||||
expect(getByText('Failed to load plugins')).toBeTruthy()
|
||||
expect(getByText(/waiting for service/)).toBeTruthy()
|
||||
expect(queryByTestId('real-ui')).toBeNull()
|
||||
})
|
||||
|
||||
it('flipping settled switches to the real UI in one pass', () => {
|
||||
const { settled, getByTestId, queryByText, counts } = mount()
|
||||
act(() => { settled.flip() })
|
||||
act(() => { settled.set(true) })
|
||||
expect(getByTestId('real-ui')).toBeTruthy()
|
||||
expect(queryByText('HARNESS')).toBeNull()
|
||||
expect(counts()).toBe(1)
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* bootWebShell over the REAL client loader in jsdom (runScripts:dangerously —
|
||||
* the loader's <script> execute path runs for real): fetch is stubbed to
|
||||
* serve fake bundle text, everything else is production code — seeded module
|
||||
* table, DSHClientProxy handoff, inject topology, renderer install after
|
||||
* settled, the one-line renderSlot('root') shell, and the fail-loud paths —
|
||||
* through the loader's fetch/execute seams (jsdom's <script> vm context
|
||||
* cannot reach the test window, so execute is indirect eval). The fake
|
||||
* runtime is the REAL SlotsService mounted by the real runtime plugin shape;
|
||||
* full-fidelity plugin content belongs to the apps/web e2e.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { act } from '@testing-library/react'
|
||||
import { bootWebShell } from '@deepseek-ai/dsh-client-web'
|
||||
import { createSnapshotStore, defineStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
interface BootWindow extends Window {
|
||||
__DSH_BOOT__?: { plugins: { id: string; url: string; inject: string[]; immediately?: boolean }[] }
|
||||
DSHClientProxy?: unknown
|
||||
__TEST_SLOTS_SERVICE__?: unknown
|
||||
__TEST_RUNTIME_STORE__?: { createSnapshotStore: unknown; defineStore: unknown }
|
||||
}
|
||||
const win = window as unknown as BootWindow
|
||||
|
||||
/**
|
||||
* Fake runtime half: mounts the REAL SlotsService (built-in 'root', ledger,
|
||||
* install/renderSlot) plus a minimal sessions face for the renderer host.
|
||||
* The runtime package is not a seeded library (in production it arrives as a
|
||||
* bundle), so the spec hands the real class in through a window global — the
|
||||
* plugin body and everything downstream stay production code.
|
||||
*/
|
||||
const RUNTIME_STUB = `
|
||||
window.DSHClientProxy.loadPlugin({
|
||||
id: 'fake-runtime',
|
||||
factory: (require) => {
|
||||
const SlotsService = window.__TEST_SLOTS_SERVICE__
|
||||
const { createSnapshotStore } = window.__TEST_RUNTIME_STORE__
|
||||
return {
|
||||
apply: (ctx) => {
|
||||
ctx.plugin(SlotsService)
|
||||
const list = createSnapshotStore({ ids: ['s1'], byId: { s1: { id: 's1', title: 'S1', displayTitle: 'S1', running: false, updatedAt: 1 } }, current: 's1' })
|
||||
ctx.provide('sessions', {
|
||||
list,
|
||||
cell: (id) => (id === 's1' ? { sessionId: 's1', session: { getSnapshot: () => ({}), subscribe: () => () => {} } } : undefined),
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
})`
|
||||
|
||||
/** Fake layout half: ONE terminal register() call — occupy 'root', declare a
|
||||
* child, seat a store factory, expose the store round trip as a probe. */
|
||||
const LAYOUT_STUB = `
|
||||
window.DSHClientProxy.loadPlugin({
|
||||
id: 'fake-layout',
|
||||
factory: (require) => {
|
||||
const React = require('react')
|
||||
const { defineStore } = window.__TEST_RUNTIME_STORE__
|
||||
return {
|
||||
inject: ['slots'],
|
||||
apply: (ctx) => {
|
||||
const createProbeStore = () => defineStore({
|
||||
init: () => ({ sidebar: 300, details: 360 }),
|
||||
actions: {
|
||||
setSidebar: (d, px) => { d.sidebar = px },
|
||||
setDetails: (d, px) => { d.details = px },
|
||||
},
|
||||
})
|
||||
ctx.slots.register({
|
||||
name: 'root',
|
||||
children: { 'probe.child': { kind: 'single', scope: 'root' } },
|
||||
store: createProbeStore,
|
||||
}, (props) => {
|
||||
const sw = props.useStore((st) => st.sidebar)
|
||||
const dw = props.useStore((st) => st.details)
|
||||
return React.createElement('div', {
|
||||
'data-testid': 'fake-frame',
|
||||
'data-widths': sw + 'x' + dw,
|
||||
onClick: () => { props.actions.setSidebar(311); props.actions.setDetails(411) },
|
||||
}, props.renderSlot('probe.child', {}))
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
})`
|
||||
|
||||
// The shell assembly requires the layout surface under its production id.
|
||||
const LAYOUT_ID = '@deepseek-ai/dsh-client-ui-layout'
|
||||
|
||||
/** Loader seams: serve fake bundle text and execute it via indirect eval (jsdom's <script> vm context cannot see the test window). */
|
||||
function seams(bundles: Record<string, string>) {
|
||||
return {
|
||||
fetchBundle: (url: string): Promise<string> => {
|
||||
const hit = Object.keys(bundles).find((b) => url.endsWith(b))
|
||||
if (hit === undefined) return Promise.reject(new Error(`bundle fetch ${url} answered 404`))
|
||||
return Promise.resolve(bundles[hit]!)
|
||||
},
|
||||
executeBundle: (code: string): void => {
|
||||
(0, eval)(code)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function mountPoint(): HTMLElement {
|
||||
const el = document.createElement('div')
|
||||
document.body.appendChild(el)
|
||||
return el
|
||||
}
|
||||
|
||||
async function flushLoader(): Promise<void> {
|
||||
// fetch + per-plugin apply chain across macrotask turns; a few settle it.
|
||||
for (let i = 0; i < 10; i++) await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
|
||||
}
|
||||
|
||||
function bootPlugins(): { id: string; url: string; inject: string[]; immediately?: boolean }[] {
|
||||
return [
|
||||
{ id: 'fake-runtime', url: '/plugins/fake-runtime.js', inject: [], immediately: true },
|
||||
{ id: LAYOUT_ID, url: '/plugins/fake-layout.js', inject: ['fake-runtime'] },
|
||||
]
|
||||
}
|
||||
|
||||
function fakeBundles(): Record<string, string> {
|
||||
return {
|
||||
'/plugins/fake-runtime.js': RUNTIME_STUB,
|
||||
'/plugins/fake-layout.js': LAYOUT_STUB.replace("id: 'fake-layout'", `id: '${LAYOUT_ID}'`),
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete win.__DSH_BOOT__
|
||||
delete win.DSHClientProxy
|
||||
delete win.__TEST_SLOTS_SERVICE__
|
||||
delete win.__TEST_RUNTIME_STORE__
|
||||
document.body.innerHTML = ''
|
||||
document.head.querySelectorAll('script').forEach((s) => { s.remove() })
|
||||
document.title = ''
|
||||
})
|
||||
|
||||
/** Hand the real runtime surface to the stub bundle (runtime is not a seeded library). */
|
||||
function seedSlotsService(): void {
|
||||
win.__TEST_SLOTS_SERVICE__ = SlotsService
|
||||
win.__TEST_RUNTIME_STORE__ = { createSnapshotStore, defineStore }
|
||||
}
|
||||
|
||||
describe('bootWebShell (real loader + real script execution)', () => {
|
||||
it('loading page → settled → renderer installed → assembled UI in one pass; unmount clears the tree', async () => {
|
||||
win.__DSH_BOOT__ = { plugins: bootPlugins() }
|
||||
seedSlotsService()
|
||||
const el = mountPoint()
|
||||
document.title = 'DeepSeek Harness'
|
||||
let unmount: (() => void) | undefined
|
||||
act(() => { unmount = bootWebShell(el, seams(fakeBundles())) })
|
||||
expect(el.textContent).toContain('HARNESS')
|
||||
expect(el.querySelector('[data-testid="fake-frame"]')).toBeNull()
|
||||
|
||||
await flushLoader()
|
||||
expect(el.querySelector('[data-testid="fake-frame"]')).not.toBeNull()
|
||||
expect(el.textContent).not.toContain('HARNESS')
|
||||
expect(document.title).toBe('S1 — DeepSeek Harness')
|
||||
|
||||
act(() => { unmount!() })
|
||||
expect(el.childElementCount).toBe(0)
|
||||
expect(document.title).toBe('DeepSeek Harness')
|
||||
})
|
||||
|
||||
it('store seat round-trips through the entry props (useStore + actions)', async () => {
|
||||
win.__DSH_BOOT__ = { plugins: bootPlugins() }
|
||||
seedSlotsService()
|
||||
const el = mountPoint()
|
||||
act(() => { bootWebShell(el, seams(fakeBundles())) })
|
||||
await flushLoader()
|
||||
const frame = el.querySelector('[data-testid="fake-frame"]')
|
||||
expect(frame).not.toBeNull()
|
||||
// Width write/read round trip through the framework-delivered store share.
|
||||
expect((frame as HTMLElement).dataset['widths']).toBe('300x360')
|
||||
act(() => { (frame as HTMLElement).click() })
|
||||
expect((frame as HTMLElement).dataset['widths']).toBe('311x411')
|
||||
})
|
||||
|
||||
it('fail loud: a 404 bundle keeps the loading page and lists the plugin id', async () => {
|
||||
win.__DSH_BOOT__ = { plugins: [{ id: 'absent-plugin', url: '/plugins/absent.js', inject: [] }] }
|
||||
const el = mountPoint()
|
||||
act(() => { bootWebShell(el, seams({})) })
|
||||
await flushLoader()
|
||||
expect(el.textContent).toContain('Failed to load plugins')
|
||||
expect(el.textContent).toContain('absent-plugin')
|
||||
expect(el.querySelector('[data-testid="fake-frame"]')).toBeNull()
|
||||
})
|
||||
|
||||
it("fail loud: rendering with no 'root' registration throws through the shell error surface", async () => {
|
||||
// Runtime loads (slots service present, renderer installed) but no layout
|
||||
// entry ever registers into 'root' — the ctx-level renderSlot must throw.
|
||||
win.__DSH_BOOT__ = {
|
||||
plugins: [{ id: 'fake-runtime', url: '/plugins/fake-runtime.js', inject: [], immediately: true }],
|
||||
}
|
||||
seedSlotsService()
|
||||
const el = mountPoint()
|
||||
// React logs the render error before the boundary rethrow reaches us — keep the spec output clean.
|
||||
const consoleError = console.error
|
||||
console.error = () => {}
|
||||
try {
|
||||
act(() => { bootWebShell(el, seams({ '/plugins/fake-runtime.js': RUNTIME_STUB })) })
|
||||
let thrown: unknown
|
||||
try {
|
||||
await flushLoader()
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
}
|
||||
expect(String(thrown)).toMatch(/'root' has no registration/)
|
||||
} finally {
|
||||
console.error = consoleError
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildRenderApp — assembly contract', () => {
|
||||
it('is exactly the ctx-level root render call (fail-loud before install)', async () => {
|
||||
const { buildRenderApp } = await import('@deepseek-ai/dsh-client-web')
|
||||
const { Context } = await import('cordis')
|
||||
const { SlotsService } = await import('@deepseek-ai/dsh-client-runtime/client')
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin(SlotsService)
|
||||
await fiber.await()
|
||||
ctx.provide('sessions', {
|
||||
list: createSnapshotStore({ ids: [], byId: {}, current: undefined }),
|
||||
})
|
||||
const renderApp = buildRenderApp({ ctx, requireModule: () => undefined })
|
||||
expect(renderApp).toBeTypeOf('function')
|
||||
// No renderer installed: the one-line shell must surface the boot-order error.
|
||||
expect(() => renderApp()).toThrow(/renderer not installed/)
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,12 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/loader"
|
||||
},
|
||||
{
|
||||
"path": "../modules"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
@@ -20,18 +26,9 @@
|
||||
{
|
||||
"path": "../web-react"
|
||||
},
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-theme"
|
||||
},
|
||||
{
|
||||
"path": "../ui-layout"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export type {
|
||||
} from './rpc.ts'
|
||||
|
||||
// ---- Errors and ids ----
|
||||
export { RpcId } from './rpc.ts'
|
||||
export { RpcId, transportError } from './rpc.ts'
|
||||
export type { RpcError, RpcErrorCode, RpcErrorDetailsMap, RpcResult } from './rpc.ts'
|
||||
|
||||
// ---- Method registry and derived generics ----
|
||||
|
||||
@@ -50,6 +50,20 @@ export type RpcError = {
|
||||
/** Business success/failure result: the result slot of a unary response; methods never throw business errors. */
|
||||
export type RpcResult<T> = { ok: true; value: T } | { ok: false; error: RpcError }
|
||||
|
||||
/**
|
||||
* Fold a transport exception into the RpcResult error branch (unified error
|
||||
* surface; 'internal' as the catch-all code). Lives with RpcResult so every
|
||||
* carrier consumer folds the same way.
|
||||
* @param error - the thrown value from the carrier.
|
||||
* @returns the error branch of an RpcResult.
|
||||
*/
|
||||
export function transportError<T>(error: unknown): RpcResult<T> {
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Signature-layer narrow form, request side (domain-interface view, shared by
|
||||
* both directions): rpcId is explicit in the signature, never mixed into the
|
||||
|
||||
@@ -32,15 +32,6 @@
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-i18n": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-question": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
||||
"@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-fs-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-policy": "workspace:^",
|
||||
@@ -73,8 +64,8 @@
|
||||
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace-context": "workspace:^",
|
||||
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^"
|
||||
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace-context": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
|
||||
@@ -11,4 +11,4 @@ export { createApiProxy } from './api-proxy.ts'
|
||||
export type { ApiProxyDefaults } from './api-proxy.ts'
|
||||
export { startHost } from './start.ts'
|
||||
export type { StartHostOptions, RunningHost } from './start.ts'
|
||||
export { mountWebPlugins, WEB_UI_PLUGINS } from './web-plugins.ts'
|
||||
export { mountWebPlugins } from './web-plugins.ts'
|
||||
@@ -1,28 +1,16 @@
|
||||
/**
|
||||
* Web UI plugin assembly: mounts @cordisjs/plugin-loader with an in-memory
|
||||
* entry tree listing the nine UI plugin packages (the P-I config-source bar —
|
||||
* a cordis.yml file form comes later; install/remove currently means editing
|
||||
* this list and restarting). The web plugin registry discovers the entries by
|
||||
* their package.json dshClient declarations; feature packages may also mount
|
||||
* their interface-specific host half through the same lifecycle.
|
||||
* Web client plugin assembly: mounts @cordisjs/plugin-loader with an in-memory
|
||||
* entry tree over the caller-supplied client plugin roster. The roster is a
|
||||
* composition decision and lives in the composing app (apps/cli); this module
|
||||
* only owns the mount/settle/fail-loud mechanics. The web plugin registry
|
||||
* discovers fetch-arrival entries among the mounted packages by their
|
||||
* package.json dshClient declarations; node halves are empty applies, so
|
||||
* mounting them here costs nothing beyond Loader governance.
|
||||
*/
|
||||
import { createRequire } from 'node:module'
|
||||
import type { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
|
||||
/** The nine UI plugin packages served to the browser (order = manifest order). */
|
||||
export const WEB_UI_PLUGINS = [
|
||||
'@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
|
||||
|
||||
/** What the shell hands the web plugin registry (loader view + module resolution seam). */
|
||||
export interface MountedWebPlugins {
|
||||
/** Entry enumeration surface of the mounted Loader (registry scan source). */
|
||||
@@ -32,31 +20,36 @@ export interface MountedWebPlugins {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the Loader (when absent) and create one in-memory entry per UI
|
||||
* plugin, then wait for the tree to settle. A plugin whose import fails
|
||||
* leaves its entry fiber-less — surfaced here as a loud throw listing the
|
||||
* failures (misconfiguration must not silently drop a UI plugin).
|
||||
* Mount the Loader (when absent) and create one in-memory entry per client
|
||||
* plugin package, then wait for the tree to settle. A plugin whose import
|
||||
* fails leaves its entry fiber-less — surfaced here as a loud throw listing
|
||||
* the failures (misconfiguration must not silently drop a client plugin).
|
||||
* @param ctx - host root context (bootHost product).
|
||||
* @param plugins - client plugin package names to mount (the composition layer's roster).
|
||||
* @param anchor - module URL anchoring bare-specifier resolution (the composing
|
||||
* app's import.meta.url; the roster packages must be dependencies of that app).
|
||||
* @returns the loader view and package.json resolver the registry consumes.
|
||||
*/
|
||||
export async function mountWebPlugins(ctx: Context): Promise<MountedWebPlugins> {
|
||||
export async function mountWebPlugins(
|
||||
ctx: Context, plugins: readonly string[], anchor: string,
|
||||
): Promise<MountedWebPlugins> {
|
||||
// The Loader resolves bare specifiers against ctx.baseUrl; without one the
|
||||
// import silently fails and every entry stays fiber-less. This package
|
||||
// depends on all nine UI plugins, so its own URL is the right anchor.
|
||||
ctx.baseUrl ??= import.meta.url
|
||||
// import silently fails and every entry stays fiber-less. The composing app
|
||||
// declares the roster packages as dependencies, so its URL is the right anchor.
|
||||
ctx.baseUrl ??= anchor
|
||||
if (ctx.get('loader') === undefined) await ctx.plugin(Loader)
|
||||
const existing = new Set([...ctx.loader.entries()].map(entry => entry.options.name))
|
||||
for (const name of WEB_UI_PLUGINS) {
|
||||
for (const name of plugins) {
|
||||
if (!existing.has(name)) await ctx.loader.create({ name })
|
||||
}
|
||||
await ctx.loader.await()
|
||||
const dead = [...ctx.loader.entries()]
|
||||
.filter(entry => (WEB_UI_PLUGINS as readonly string[]).includes(entry.options.name))
|
||||
.filter(entry => plugins.includes(entry.options.name))
|
||||
.filter(entry => entry.fiber === undefined && !entry.disabled)
|
||||
if (dead.length > 0) {
|
||||
throw new Error(`web-plugins: UI plugin(s) failed to load: ${dead.map(e => e.options.name).join(', ')}`)
|
||||
throw new Error(`web-plugins: client plugin(s) failed to load: ${dead.map(e => e.options.name).join(', ')}`)
|
||||
}
|
||||
const require = createRequire(import.meta.url)
|
||||
const require = createRequire(anchor)
|
||||
return {
|
||||
loader: ctx.loader,
|
||||
resolvePkgJson: name => require.resolve(`${name}/package.json`),
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
/**
|
||||
* Web UI plugin assembly: the in-memory Loader tree mounts all nine UI
|
||||
* packages (node halves), and the webserver registry built over it yields the
|
||||
* full __DSH_BOOT__ manifest — the P-I config-source bar end to end.
|
||||
*
|
||||
* The Loader imports plugin packages through their exports maps (lib/), so
|
||||
* this is a built-artifact e2e: it skips until the workspace build has run
|
||||
* (`pnpm run build`), like the other built-* e2e suites.
|
||||
*/
|
||||
import { existsSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { Context } from 'cordis'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createHostWebPluginRegistry } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { WEB_UI_PLUGINS, mountWebPlugins } from '../src/web-plugins.ts'
|
||||
|
||||
const nodeRequire = createRequire(import.meta.url)
|
||||
const built = WEB_UI_PLUGINS.every((name) => {
|
||||
try {
|
||||
return existsSync(nodeRequire.resolve(name))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
let root: Context | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await root?.fiber.dispose()
|
||||
root = undefined
|
||||
})
|
||||
|
||||
describe.skipIf(!built)('mountWebPlugins + registry', () => {
|
||||
async function rootWithHostServices(): Promise<Context> {
|
||||
root = new Context()
|
||||
await root.plugin(SystemPrompt)
|
||||
await root.plugin(ToolRegistry)
|
||||
await root.plugin(UserInteractionService)
|
||||
return root
|
||||
}
|
||||
|
||||
it('mounts the nine-package in-memory Loader tree and projects the boot manifest', async () => {
|
||||
root = await rootWithHostServices()
|
||||
const mounted = await mountWebPlugins(root)
|
||||
const registry = createHostWebPluginRegistry({
|
||||
ctx: root,
|
||||
loader: mounted.loader,
|
||||
resolvePkgJson: mounted.resolvePkgJson,
|
||||
onError: (err) => { throw err },
|
||||
})
|
||||
const rows = registry.snapshot()
|
||||
expect(rows.map(r => r.id)).toEqual([...WEB_UI_PLUGINS])
|
||||
// The infra four are the early-load group; the UI four are not.
|
||||
const immediate = rows.filter(r => r.immediately === true).map(r => r.id)
|
||||
expect(immediate).toEqual([
|
||||
'@deepseek-ai/dsh-client-connection',
|
||||
'@deepseek-ai/dsh-client-runtime',
|
||||
'@deepseek-ai/dsh-client-ui-theme',
|
||||
'@deepseek-ai/dsh-client-i18n',
|
||||
])
|
||||
// Every row resolves a client path under its own package lib/.
|
||||
for (const row of rows) {
|
||||
expect(registry.clientPath(row.id)).toMatch(/lib[/\\]client\.js$/)
|
||||
expect(row.url).toBe(`/plugins/${row.id}/client.js`)
|
||||
}
|
||||
registry.dispose()
|
||||
})
|
||||
|
||||
it('is idempotent: a second mount reuses the loader and creates no duplicate entries', async () => {
|
||||
root = await rootWithHostServices()
|
||||
await mountWebPlugins(root)
|
||||
const second = await mountWebPlugins(root)
|
||||
// ctx.loader hands out a fresh traced proxy per access, so loader identity
|
||||
// is not assertable; the observable contract is a single entry per package.
|
||||
const names = [...second.loader.entries()].map(e => e.options.name)
|
||||
.filter(n => (WEB_UI_PLUGINS as readonly string[]).includes(n))
|
||||
expect(names.length).toBe(WEB_UI_PLUGINS.length)
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,20 @@
|
||||
/**
|
||||
* mountWebPlugins unit coverage (keyless; the real nine-package walk is the
|
||||
* built-artifact e2e). The Loader-facing behavior — baseUrl anchoring, entry
|
||||
* creation with idempotent reuse, the fiber-less fail-loud sweep, and the
|
||||
* resolver seam — is exercised against a stubbed loader service so it runs
|
||||
* without built lib/ artifacts.
|
||||
* mountWebPlugins unit coverage (keyless). The Loader-facing behavior —
|
||||
* baseUrl anchoring, entry creation with idempotent reuse, the fiber-less
|
||||
* fail-loud sweep, and the resolver seam — is exercised against a stubbed
|
||||
* loader service so it runs without built lib/ artifacts. The roster is
|
||||
* caller-supplied now (composition moved to apps/cli), so these tests pass
|
||||
* their own lists.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { WEB_UI_PLUGINS, mountWebPlugins } from '../src/web-plugins.ts'
|
||||
import { mountWebPlugins } from '../src/web-plugins.ts'
|
||||
|
||||
const ROSTER = [
|
||||
'@deepseek-ai/dsh-plugin-a',
|
||||
'@deepseek-ai/dsh-plugin-b',
|
||||
'@deepseek-ai/dsh-plugin-c',
|
||||
] as const
|
||||
|
||||
interface FakeEntry {
|
||||
options: { name: string }
|
||||
@@ -47,60 +54,50 @@ function withLoader(entriesList: FakeEntry[], onCreate?: (name: string) => void)
|
||||
}
|
||||
|
||||
describe('mountWebPlugins (stubbed loader)', () => {
|
||||
it('creates one entry per UI plugin, awaits the tree, and returns the loader view + resolver', async () => {
|
||||
it('creates one entry per roster package, awaits the tree, and returns the loader view + resolver', async () => {
|
||||
const entriesList: FakeEntry[] = []
|
||||
const { ctx, loader } = withLoader(entriesList, (name) => {
|
||||
entriesList.push({ options: { name }, fiber: {}, disabled: false })
|
||||
})
|
||||
const mounted = await mountWebPlugins(ctx)
|
||||
expect(loader.created).toEqual([...WEB_UI_PLUGINS])
|
||||
const mounted = await mountWebPlugins(ctx, ROSTER, import.meta.url)
|
||||
expect(loader.created).toEqual([...ROSTER])
|
||||
expect(loader.awaited).toBe(1)
|
||||
expect([...mounted.loader.entries()].map(e => e.options.name)).toEqual([...WEB_UI_PLUGINS])
|
||||
// The resolver resolves this package's own manifest through real module resolution.
|
||||
expect([...mounted.loader.entries()].map(e => e.options.name)).toEqual([...ROSTER])
|
||||
// The resolver resolves a real package manifest through real module resolution, anchored at this test file.
|
||||
expect(mounted.resolvePkgJson('@deepseek-ai/dsh-host-runtime')).toMatch(/package\.json$/)
|
||||
expect(ctx.baseUrl).toBeDefined()
|
||||
})
|
||||
|
||||
it('reuses existing entries (idempotent mount creates no duplicates)', async () => {
|
||||
const preexisting: FakeEntry[] = WEB_UI_PLUGINS.map(name => ({ options: { name }, fiber: {}, disabled: false }))
|
||||
const preexisting: FakeEntry[] = ROSTER.map(name => ({ options: { name }, fiber: {}, disabled: false }))
|
||||
const { ctx, loader } = withLoader(preexisting)
|
||||
await mountWebPlugins(ctx)
|
||||
await mountWebPlugins(ctx, ROSTER, import.meta.url)
|
||||
expect(loader.created).toEqual([])
|
||||
})
|
||||
|
||||
it('throws listing every fiber-less entry (silent import failure must not drop a UI plugin)', async () => {
|
||||
it('throws listing every fiber-less entry (silent import failure must not drop a client plugin)', async () => {
|
||||
const entriesList: FakeEntry[] = []
|
||||
const { ctx } = withLoader(entriesList, (name) => {
|
||||
// First two load; the rest stay fiber-less (import failed silently).
|
||||
entriesList.push({ options: { name }, fiber: entriesList.length < 2 ? {} : undefined, disabled: false })
|
||||
// First one loads; the rest stay fiber-less (import failed silently).
|
||||
entriesList.push({ options: { name }, fiber: entriesList.length < 1 ? {} : undefined, disabled: false })
|
||||
})
|
||||
await expect(mountWebPlugins(ctx)).rejects.toThrow(/UI plugin\(s\) failed to load: .*dsh-client-ui-theme/)
|
||||
await expect(mountWebPlugins(ctx, ROSTER, import.meta.url))
|
||||
.rejects.toThrow(/client plugin\(s\) failed to load: .*dsh-plugin-c/)
|
||||
})
|
||||
|
||||
it('skips disabled entries in the fail-loud sweep (disabled is the one valid fiber-less state)', async () => {
|
||||
const entriesList: FakeEntry[] = WEB_UI_PLUGINS.map(name => ({ options: { name }, fiber: undefined, disabled: true }))
|
||||
const entriesList: FakeEntry[] = ROSTER.map(name => ({ options: { name }, fiber: undefined, disabled: true }))
|
||||
const { ctx } = withLoader(entriesList)
|
||||
await expect(mountWebPlugins(ctx)).resolves.toBeDefined()
|
||||
await expect(mountWebPlugins(ctx, ROSTER, import.meta.url)).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('mounts the real Loader when none is present (the ctx.plugin(Loader) branch)', async () => {
|
||||
root = new Context()
|
||||
// Environment-dependent outcome: with built lib/ the nine imports load
|
||||
// and the mount resolves; without them every entry stays fiber-less and
|
||||
// the sweep throws its loud list. Either way the branch under test is the
|
||||
// Loader auto-mount. Manual try/catch keeps cordis-traced proxies out of
|
||||
// expect()'s formatting path (pretty-format probes throw on them).
|
||||
// Plain string: the success sentinel and error text share one channel.
|
||||
let outcome: string
|
||||
try {
|
||||
await mountWebPlugins(root)
|
||||
outcome = 'resolved'
|
||||
} catch (error) {
|
||||
outcome = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
expect(outcome === 'resolved' || /UI plugin\(s\) failed to load/.test(outcome)).toBe(true)
|
||||
// An empty roster keeps this keyless and artifact-free: the branch under
|
||||
// test is only the Loader auto-mount.
|
||||
await mountWebPlugins(root, [], import.meta.url)
|
||||
expect(root.get('loader') !== undefined).toBe(true)
|
||||
}, 30_000) // built-env run imports nine real plugin packages through the Loader
|
||||
}, 30_000) // cold-cache import of the real vendored Loader crosses the network-disk 5s default
|
||||
|
||||
it('keeps a caller-set baseUrl (anchors only when absent)', async () => {
|
||||
const entriesList: FakeEntry[] = []
|
||||
@@ -108,7 +105,7 @@ describe('mountWebPlugins (stubbed loader)', () => {
|
||||
entriesList.push({ options: { name }, fiber: {}, disabled: false })
|
||||
})
|
||||
ctx.baseUrl = 'file:///caller/anchor/'
|
||||
await mountWebPlugins(ctx)
|
||||
await mountWebPlugins(ctx, ROSTER, import.meta.url)
|
||||
expect(ctx.baseUrl).toBe('file:///caller/anchor/')
|
||||
})
|
||||
})
|
||||
@@ -68,9 +68,6 @@
|
||||
{
|
||||
"path": "../../fs/tool-fs-search"
|
||||
},
|
||||
{
|
||||
"path": "../../context/workspace-context"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/token-meter"
|
||||
},
|
||||
@@ -126,31 +123,10 @@
|
||||
"path": "../../../vendor/loader"
|
||||
},
|
||||
{
|
||||
"path": "../../client/connection"
|
||||
"path": "../../context/workspace-context"
|
||||
},
|
||||
{
|
||||
"path": "../../client/runtime"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-theme"
|
||||
},
|
||||
{
|
||||
"path": "../../client/i18n"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-layout"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-sidebar"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-conversation"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-question"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-trajectory"
|
||||
"path": "../../ui/user-interaction"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -13,12 +13,14 @@ import { readFile } from 'node:fs/promises'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { dirname } from 'node:path'
|
||||
import { serveStatic } from './static.ts'
|
||||
import type { HostWebPluginRegistry } from './web-plugins.ts'
|
||||
import { createPluginEventChannel } from './plugin-events.ts'
|
||||
import type { HostWebPluginRegistry, WebBootGraph } from './web-plugins.ts'
|
||||
|
||||
export { createHostWebPluginRegistry } from './web-plugins.ts'
|
||||
export type {
|
||||
HostWebPluginRegistry, LoaderEntryView, LoaderView, WebPluginBootEntry, WebPluginRegistryDeps,
|
||||
HostWebPluginRegistry, LoaderEntryView, LoaderView, WebBootEntry, WebBootGraph, WebPluginRegistryDeps,
|
||||
} from './web-plugins.ts'
|
||||
export type { PluginEventChannel, PluginEventFrame } from './plugin-events.ts'
|
||||
|
||||
/** Options for startWebServer. */
|
||||
export interface WebServerOptions {
|
||||
@@ -34,11 +36,14 @@ export interface WebServerOptions {
|
||||
/** Fetch-shaped API carrier; /api/*-prefixed requests are bridged to it. */
|
||||
apiHandler: { fetch: typeof fetch }
|
||||
/**
|
||||
* Web plugin table. When present, every index.html response carries a
|
||||
* `window.__DSH_BOOT__` manifest script and `/plugins/<id>/client.js` serves
|
||||
* each plugin's client bundle. Absent = both surfaces off (carrier-only use).
|
||||
* Web plugin table. When present, every index.html response carries the
|
||||
* `window.__DSH_BOOT__` entry graph script, `/plugins/<id>/client.js` serves
|
||||
* each fetch entry's client bundle, and `GET /plugins/events` streams graph/
|
||||
* rebuilt frames (SSE) — rebuilt frames ride the registry's own bundle-watch
|
||||
* notifications (`onRebuilt`). Absent = all three surfaces off (carrier-only
|
||||
* use).
|
||||
*/
|
||||
webPlugins?: Pick<HostWebPluginRegistry, 'snapshot' | 'clientPath'>
|
||||
webPlugins?: Pick<HostWebPluginRegistry, 'graph' | 'clientPath' | 'onRebuilt'>
|
||||
}
|
||||
|
||||
/** Listening web server handle. */
|
||||
@@ -70,8 +75,14 @@ export function startWebServer(options: WebServerOptions, onError: (err: Error)
|
||||
const distRoot = dirname(distIndex)
|
||||
const renderIndex = webPlugins === undefined ? undefined : async (): Promise<string> => {
|
||||
const html = await readFile(distIndex, 'utf8')
|
||||
return injectBootManifest(html, webPlugins.snapshot())
|
||||
return injectBootManifest(html, webPlugins.graph())
|
||||
}
|
||||
const pluginEvents = webPlugins === undefined ? undefined : createPluginEventChannel()
|
||||
// Rebuilt frames come from the registry's own bundle watch (dev mode); a
|
||||
// prod registry without watching simply never notifies.
|
||||
const unsubscribeRebuilt = webPlugins !== undefined && pluginEvents !== undefined
|
||||
? webPlugins.onRebuilt((id, rev) => { pluginEvents.broadcast({ type: 'rebuilt', id, rev }) })
|
||||
: undefined
|
||||
|
||||
const handle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
||||
/* v8 ignore next -- `?? '/'` arm: node:http always sets url on server
|
||||
@@ -86,6 +97,10 @@ export function startWebServer(options: WebServerOptions, onError: (err: Error)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
if (webPlugins !== undefined && pluginEvents !== undefined && rawPath === '/plugins/events') {
|
||||
pluginEvents.connect(res, webPlugins.graph())
|
||||
return
|
||||
}
|
||||
if (webPlugins !== undefined && rawPath.startsWith('/plugins/') && rawPath.endsWith('/client.js')) {
|
||||
await servePluginBundle(decodeURIComponent(rawPath), res, webPlugins)
|
||||
return
|
||||
@@ -110,6 +125,7 @@ export function startWebServer(options: WebServerOptions, onError: (err: Error)
|
||||
|
||||
let closing: Promise<void> | undefined
|
||||
const close = (): Promise<void> => (closing ??= new Promise((resolveClose) => {
|
||||
unsubscribeRebuilt?.()
|
||||
server.close(() => { resolveClose() })
|
||||
server.closeAllConnections()
|
||||
}))
|
||||
@@ -125,15 +141,15 @@ export function startWebServer(options: WebServerOptions, onError: (err: Error)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject the boot manifest into index.html: `window.__DSH_BOOT__` as the first
|
||||
* script in <head> (before the shell bundle reads it). `<` is escaped in the
|
||||
* JSON so plugin-controlled strings cannot break out of the script element.
|
||||
* Inject the boot entry graph into index.html: `window.__DSH_BOOT__` as the
|
||||
* first script in <head> (before the shell bundle reads it). `<` is escaped in
|
||||
* the JSON so plugin-controlled strings cannot break out of the script element.
|
||||
* @param html - the index.html source.
|
||||
* @param plugins - the manifest rows from the registry snapshot.
|
||||
* @returns the html with the manifest script injected.
|
||||
* @param graph - the composed entry graph from the registry.
|
||||
* @returns the html with the graph script injected.
|
||||
*/
|
||||
export function injectBootManifest(html: string, plugins: readonly unknown[]): string {
|
||||
const json = JSON.stringify({ plugins }).replaceAll('<', '\\u003c')
|
||||
export function injectBootManifest(html: string, graph: WebBootGraph): string {
|
||||
const json = JSON.stringify(graph).replaceAll('<', '\\u003c')
|
||||
const script = `<script>window.__DSH_BOOT__ = ${json}</script>`
|
||||
const head = html.indexOf('<head>')
|
||||
if (head !== -1) return `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}`
|
||||
@@ -141,7 +157,12 @@ export function injectBootManifest(html: string, plugins: readonly unknown[]): s
|
||||
return `${script}${html}`
|
||||
}
|
||||
|
||||
/** Serve one plugin client bundle from the registry table (unknown id = 404; the id may contain a scope slash). */
|
||||
/**
|
||||
* Serve one plugin client bundle from the registry table (unknown id = 404;
|
||||
* the id may contain a scope slash). The `?rev=` query is a cache-busting
|
||||
* parameter only — serving ignores it; `no-cache` makes the browser revalidate
|
||||
* so a stale rev never sticks.
|
||||
*/
|
||||
async function servePluginBundle(
|
||||
pathname: string, res: ServerResponse, webPlugins: Pick<HostWebPluginRegistry, 'clientPath'>,
|
||||
): Promise<void> {
|
||||
@@ -154,7 +175,7 @@ async function servePluginBundle(
|
||||
}
|
||||
try {
|
||||
const body = await readFile(path)
|
||||
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' })
|
||||
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache' })
|
||||
res.end(body)
|
||||
} catch {
|
||||
// Registered but unreadable (bundle not built yet): loud 404 beats a silent SPA-fallback HTML page.
|
||||
|
||||
@@ -15,25 +15,27 @@ export const name = 'host-webserver-invariant'
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* Owned relation: the web plugin registry's boot manifest must stay
|
||||
* self-consistent — every snapshot() row must resolve a clientPath under the
|
||||
* same id (the /plugins/<id>/client.js URL it advertises would otherwise 404
|
||||
* on a browser that just received the manifest). Checked synchronously on
|
||||
* every rescan trigger (cordis 'internal/plugin'): snapshot() and
|
||||
* clientPath() read the same table object, so the relation is
|
||||
* self-consistent at any instant — no need to wait out the registry's own
|
||||
* debounced rescan. The registry arrives through the context key the
|
||||
* assembly publishes it under.
|
||||
* Owned relation: the web plugin registry's boot entry graph must stay
|
||||
* self-consistent — every row must resolve a clientPath under the same id
|
||||
* (the /plugins/<id>/client.js URL it advertises would otherwise 404 on a
|
||||
* browser that just received the graph). Checked synchronously on every
|
||||
* rescan trigger (cordis 'internal/plugin'): graph() and clientPath() read
|
||||
* the same table object, so the relation is self-consistent at any instant —
|
||||
* no need to wait out the registry's own debounced rescan. The registry
|
||||
* arrives through the context key the assembly publishes it under.
|
||||
*/
|
||||
const install: InvariantInstaller = (ctx, fail) => {
|
||||
ctx.on('internal/plugin', () => {
|
||||
const registry = ctx.get('webPlugins') as
|
||||
| { snapshot(): { id: string; url: string }[]; clientPath(id: string): string | undefined }
|
||||
| {
|
||||
graph(): { entries: { id: string; url: string }[] }
|
||||
clientPath(id: string): string | undefined
|
||||
}
|
||||
| undefined
|
||||
if (registry === undefined) return // carrier-only deployments never publish the registry
|
||||
for (const row of registry.snapshot()) {
|
||||
for (const row of registry.graph().entries) {
|
||||
if (registry.clientPath(row.id) === undefined) {
|
||||
fail(`web plugin manifest row "${row.id}" advertises ${row.url} but resolves no client bundle path — the served __DSH_BOOT__ would 404 on fetch`)
|
||||
fail(`web plugin graph row "${row.id}" advertises ${row.url} but resolves no client bundle path — the served __DSH_BOOT__ would 404 on fetch`)
|
||||
}
|
||||
}
|
||||
}, { global: true })
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* `/plugins/events` SSE channel: the system-side push surface for the client
|
||||
* entry graph (connect → current graph frame; dev rebuild → rebuilt frame).
|
||||
* Presentation-only wire — frames never enter the session log (distinct from
|
||||
* the /api/* session SSE, which is api-contract territory). Connections are
|
||||
* plain node:http responses held in a set; the server's closeAllConnections
|
||||
* tears them down on shutdown.
|
||||
*/
|
||||
|
||||
import type { ServerResponse } from 'node:http'
|
||||
import type { WebBootGraph } from './web-plugins.ts'
|
||||
|
||||
/** One `/plugins/events` frame: the full graph on connect, or one rebuilt bundle notice. */
|
||||
export type PluginEventFrame =
|
||||
| { type: 'graph'; graph: WebBootGraph }
|
||||
| { type: 'rebuilt'; id: string; rev: string }
|
||||
|
||||
/** Broadcast surface owned by the webserver routing layer. */
|
||||
export interface PluginEventChannel {
|
||||
/** Adopt one incoming SSE request: writes the SSE preamble and the current-graph frame, then keeps the response open. */
|
||||
connect(res: ServerResponse, graph: WebBootGraph): void
|
||||
/** Push one frame to every open connection. */
|
||||
broadcast(frame: PluginEventFrame): void
|
||||
}
|
||||
|
||||
/** Serialize one frame as an SSE data line. */
|
||||
function sseData(frame: PluginEventFrame): string {
|
||||
return `data: ${JSON.stringify(frame)}\n\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the channel (one per running server).
|
||||
* @returns the connect/broadcast surface.
|
||||
*/
|
||||
export function createPluginEventChannel(): PluginEventChannel {
|
||||
const connections = new Set<ServerResponse>()
|
||||
return {
|
||||
connect(res, graph) {
|
||||
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 }))
|
||||
connections.add(res)
|
||||
res.on('close', () => { connections.delete(res) })
|
||||
},
|
||||
broadcast(frame) {
|
||||
const line = sseData(frame)
|
||||
for (const res of connections) res.write(line)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
/**
|
||||
* HostWebPluginRegistry: discovers web-client plugins among the host Loader's
|
||||
* loaded entries by their package.json `dshClient` declaration and resolves
|
||||
* each one's client bundle path from `exports["./client"]`. The webserver
|
||||
* consumes the table to emit `window.__DSH_BOOT__` and to serve
|
||||
* `GET /plugins/<id>/client.js`. Discovery is declaration-only: plugin authors
|
||||
* write package.json; no serve() call surface exists.
|
||||
* HostWebPluginRegistry: composes the client entry graph served as
|
||||
* `window.__DSH_BOOT__` ({rev, entries}). Every row is discovered among the
|
||||
* host Loader's loaded entries by its package.json `dshClient` declaration
|
||||
* (all client plugin packages arrive by fetch — one uniform bundle shape),
|
||||
* resolving each one's client bundle path from `exports["./client"]` and
|
||||
* hashing the bundle content into a `rev` (cache busting + HMR diff anchor).
|
||||
* `inject` edges and the `immediately` prefetch mark come from the manifest
|
||||
* (dshClient — the package owns its dependency edges and its boot tier); the
|
||||
* composition layer contributes only the roster. The webserver consumes the
|
||||
* table to emit the boot graph and to serve `GET /plugins/<id>/client.js`;
|
||||
* in dev mode the registry additionally stat-polls each scanned bundle file
|
||||
* and re-hashes + notifies `onRebuilt` subscribers on change (the rebuild
|
||||
* signal is the registry's own observation — no builder protocol exists).
|
||||
*
|
||||
* The vendored loader emits no "entry loaded" event (only `loader/entry-init`,
|
||||
* which fires at Entry construction before import/apply), so the registry
|
||||
@@ -14,33 +21,59 @@
|
||||
* fresh within a process lifetime.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFileSync, unwatchFile, watchFile } from 'node:fs'
|
||||
import type { Stats } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
/** One `window.__DSH_BOOT__.plugins` row (wire shape of api-contracts v3 §9.2). */
|
||||
export interface WebPluginBootEntry {
|
||||
/** Plugin id = package name (may contain a scope slash). */
|
||||
/** One composed client entry (`window.__DSH_BOOT__.entries` row). */
|
||||
export interface WebBootEntry {
|
||||
/** Entry name == package name. */
|
||||
id: string
|
||||
/** Bundle URL served by this webserver (`/plugins/<id>/client.js`). */
|
||||
/** Bundle URL served by this webserver (`/plugins/<id>/client.js?rev=<rev>`). */
|
||||
url: string
|
||||
/** Client-half load dependencies (plugin ids), topologically ordered by the client loader. */
|
||||
inject: string[]
|
||||
/** Marks the early-load group: fetched in parallel and applied before all other plugins. */
|
||||
/** Bundle content hash (sha1, shortened). */
|
||||
rev: string
|
||||
/** Package-name dependency edges from the manifest (dshClient.inject), informational (preflight/HMR display). */
|
||||
inject?: string[]
|
||||
/** Boot phase-one prefetch tier: the shell fetches these bundles in parallel before creating entries. */
|
||||
immediately?: boolean
|
||||
}
|
||||
|
||||
/** The web plugin table consumed by the boot injection and the bundle endpoint. */
|
||||
/** The composed entry graph: injected into index.html and pushed on /plugins/events connect. */
|
||||
export interface WebBootGraph {
|
||||
/** Consistency anchor over all rows: changes whenever any entry row changes. */
|
||||
rev: string
|
||||
/** All composed entries (order carries no semantics; governance ordering is the client Loader's job). */
|
||||
entries: WebBootEntry[]
|
||||
}
|
||||
|
||||
/** The web plugin table consumed by the boot injection, the bundle endpoint, and the rebuild channel. */
|
||||
export interface HostWebPluginRegistry {
|
||||
/** Current manifest rows (stable order: loader entry order). */
|
||||
snapshot(): WebPluginBootEntry[]
|
||||
/** Current composed entry graph (stable object between changes). */
|
||||
graph(): WebBootGraph
|
||||
/**
|
||||
* Absolute path of a plugin's client bundle.
|
||||
* @param id - plugin id (package name).
|
||||
* 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
|
||||
/** Remove the loader subscription. */
|
||||
/**
|
||||
* Re-hash one entry's bundle: updates the row's rev/url and the graph rev.
|
||||
* The dev bundle watch calls this on every observed file change.
|
||||
* @param id - entry id (package name).
|
||||
* @returns the new bundle rev, or undefined for an unknown id.
|
||||
*/
|
||||
rebuilt(id: string): string | undefined
|
||||
/**
|
||||
* Subscribe to bundle rebuilds observed by the dev watch (only fires when
|
||||
* the re-hash produced a different rev — an unchanged bundle is silent).
|
||||
* @param listener - receives the entry id and its new bundle rev.
|
||||
* @returns the unsubscriber.
|
||||
*/
|
||||
onRebuilt(listener: (id: string, rev: string) => void): () => void
|
||||
/** Remove the loader subscription, all bundle watches, and all rebuild listeners. */
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
@@ -72,17 +105,28 @@ export interface WebPluginRegistryDeps {
|
||||
resolvePkgJson: (name: string) => string
|
||||
/** Sink for rescan failures (the initial scan throws instead — misconfiguration fails loud at load). */
|
||||
onError: (err: Error) => void
|
||||
/**
|
||||
* Dev-mode bundle watching: stat-poll every scanned row's client bundle
|
||||
* (fs.watchFile — polling by design: network mounts deliver no inotify
|
||||
* events) and re-hash + notify onRebuilt subscribers on change. Absent =
|
||||
* no watching (prod composition).
|
||||
*/
|
||||
watch?: {
|
||||
/** Stat-poll interval in milliseconds; default 500 (the build-side watcher's polling default). */
|
||||
intervalMs?: number
|
||||
}
|
||||
}
|
||||
|
||||
/** package.json `dshClient` declaration shape (file boundary — validated field by field). */
|
||||
interface DshClientDeclaration {
|
||||
inject?: string[]
|
||||
platform: string
|
||||
/** Boot phase-one prefetch mark; absent means lazy (fetched on demand). */
|
||||
immediately?: boolean
|
||||
}
|
||||
|
||||
interface WebPluginRecord {
|
||||
entry: WebPluginBootEntry
|
||||
entry: WebBootEntry
|
||||
clientPath: string
|
||||
}
|
||||
|
||||
@@ -122,15 +166,102 @@ function clientExportOf(name: string, exportsField: unknown): string | undefined
|
||||
throw new Error(`web-plugins: ${name} exports["./client"] has an unsupported shape`)
|
||||
}
|
||||
|
||||
/** sha1 content hash shortened to 12 hex chars (bundle rev / graph rev). */
|
||||
function shortHash(input: string | Buffer): string {
|
||||
return createHash('sha1').update(input).digest('hex').slice(0, 12)
|
||||
}
|
||||
|
||||
/** Graph row for one bundle rev (url carries the rev as its cache-busting query). */
|
||||
function graphRow(id: string, rev: string, inject: string[] | undefined, immediately: boolean): WebBootEntry {
|
||||
return {
|
||||
id,
|
||||
url: `/plugins/${id}/client.js?rev=${rev}`,
|
||||
rev,
|
||||
...(inject !== undefined ? { inject } : {}),
|
||||
...(immediately ? { immediately: true } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Compose the graph value from the current table. */
|
||||
function composeGraph(table: Map<string, WebPluginRecord>): WebBootGraph {
|
||||
const entries = [...table.values()].map(record => record.entry)
|
||||
return { rev: shortHash(JSON.stringify(entries)), entries }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the web plugin registry: scan once synchronously (a malformed
|
||||
* declaration throws here — load-time fail loud), then rescan on
|
||||
* `internal/plugin`, microtask-debounced (failures go to `deps.onError`).
|
||||
* @param deps - loader view, resolution hook, and error sink (see {@link WebPluginRegistryDeps}).
|
||||
* declaration, an unbuilt bundle, or an invalid watch interval throws here —
|
||||
* load-time fail loud), then rescan on `internal/plugin`, microtask-debounced
|
||||
* (failures go to `deps.onError`). With `deps.watch`, every scanned bundle
|
||||
* file is stat-polled and a content change re-hashes the row and notifies
|
||||
* `onRebuilt` subscribers.
|
||||
* @param deps - loader view, resolution hook, error sink, and optional dev watch (see {@link WebPluginRegistryDeps}).
|
||||
* @returns the registry handle.
|
||||
*/
|
||||
export function createHostWebPluginRegistry(deps: WebPluginRegistryDeps): HostWebPluginRegistry {
|
||||
const watchInterval = deps.watch === undefined ? undefined : deps.watch.intervalMs ?? 500
|
||||
if (watchInterval !== undefined && (!Number.isInteger(watchInterval) || watchInterval <= 0)) {
|
||||
throw new Error(`web-plugins: watch.intervalMs must be a positive integer (got ${String(deps.watch?.intervalMs)})`)
|
||||
}
|
||||
|
||||
let table = scan(deps)
|
||||
let graph = composeGraph(table)
|
||||
const rebuildListeners = new Set<(id: string, rev: string) => void>()
|
||||
|
||||
const rebuilt = (id: string): string | undefined => {
|
||||
const record = table.get(id)
|
||||
if (record === undefined) return undefined
|
||||
const rev = shortHash(readFileSync(record.clientPath))
|
||||
record.entry = graphRow(id, rev, record.entry.inject, record.entry.immediately === true)
|
||||
graph = composeGraph(table)
|
||||
return rev
|
||||
}
|
||||
|
||||
// Dev bundle watch: one fs.watchFile stat poll per table row. A torn read
|
||||
// of a half-written bundle self-heals — the ongoing write keeps changing
|
||||
// the stats, so the next poll tick re-hashes the completed file.
|
||||
const watched = new Map<string, { path: string; listener: (curr: Stats, prev: Stats) => void }>()
|
||||
const syncWatches = (): void => {
|
||||
if (watchInterval === undefined) return
|
||||
for (const [id, watch] of watched) {
|
||||
if (table.get(id)?.clientPath === watch.path) continue
|
||||
unwatchFile(watch.path, watch.listener)
|
||||
watched.delete(id)
|
||||
}
|
||||
for (const [id, record] of table) {
|
||||
if (watched.has(id)) continue
|
||||
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
|
||||
const before = table.get(id)?.entry.rev
|
||||
let rev: string | undefined
|
||||
try {
|
||||
rev = 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
|
||||
deps.onError(error instanceof Error ? error : new Error(String(error)))
|
||||
return
|
||||
}
|
||||
if (rev === undefined || rev === before) return
|
||||
for (const notify of rebuildListeners) {
|
||||
// A throwing subscriber must not escape the fs.watchFile callback
|
||||
// (that would skip later subscribers and can kill the process).
|
||||
try {
|
||||
notify(id, rev)
|
||||
} catch (error) {
|
||||
deps.onError(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
}
|
||||
}
|
||||
watchFile(record.clientPath, { interval: watchInterval, persistent: false }, listener)
|
||||
watched.set(id, { path: record.clientPath, listener })
|
||||
}
|
||||
}
|
||||
syncWatches()
|
||||
|
||||
let pending = false
|
||||
const unsubscribe = deps.ctx.on('internal/plugin', () => {
|
||||
@@ -140,8 +271,10 @@ export function createHostWebPluginRegistry(deps: WebPluginRegistryDeps): HostWe
|
||||
pending = false
|
||||
try {
|
||||
table = scan(deps)
|
||||
graph = composeGraph(table)
|
||||
syncWatches()
|
||||
} catch (error) {
|
||||
// Keep serving the previous table: a mid-flight rescan failure must not
|
||||
// Keep serving the previous graph: a mid-flight rescan failure must not
|
||||
// take down the boot manifest for plugins that were fine.
|
||||
deps.onError(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
@@ -149,13 +282,23 @@ export function createHostWebPluginRegistry(deps: WebPluginRegistryDeps): HostWe
|
||||
})
|
||||
|
||||
return {
|
||||
snapshot: () => [...table.values()].map(record => record.entry),
|
||||
graph: () => graph,
|
||||
clientPath: id => table.get(id)?.clientPath,
|
||||
dispose: () => { unsubscribe() },
|
||||
rebuilt,
|
||||
onRebuilt: (listener) => {
|
||||
rebuildListeners.add(listener)
|
||||
return () => { rebuildListeners.delete(listener) }
|
||||
},
|
||||
dispose: () => {
|
||||
unsubscribe()
|
||||
for (const { path, listener } of watched.values()) unwatchFile(path, listener)
|
||||
watched.clear()
|
||||
rebuildListeners.clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** One full table build from the loader's current entries. */
|
||||
/** One full table build from the loader's current entries (bundle content is hashed here — an unreadable bundle throws). */
|
||||
function scan(deps: WebPluginRegistryDeps): Map<string, WebPluginRecord> {
|
||||
const table = new Map<string, WebPluginRecord>()
|
||||
for (const entry of deps.loader.entries()) {
|
||||
@@ -170,15 +313,9 @@ function scan(deps: WebPluginRegistryDeps): Map<string, WebPluginRecord> {
|
||||
if (clientRel === undefined) {
|
||||
throw new Error(`web-plugins: ${name} declares dshClient but exports no "./client" bundle`)
|
||||
}
|
||||
table.set(name, {
|
||||
entry: {
|
||||
id: name,
|
||||
url: `/plugins/${name}/client.js`,
|
||||
inject: decl.inject ?? [],
|
||||
...(decl.immediately === true ? { immediately: true } : {}),
|
||||
},
|
||||
clientPath: join(dirname(pkgPath), clientRel),
|
||||
})
|
||||
const clientPath = join(dirname(pkgPath), clientRel)
|
||||
const rev = shortHash(readFileSync(clientPath))
|
||||
table.set(name, { entry: graphRow(name, rev, decl.inject, decl.immediately === true), clientPath })
|
||||
}
|
||||
return table
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Webserver invariant companion: the boot-manifest consistency audit — every
|
||||
* registry snapshot row must resolve a clientPath, checked on fiber lifecycle
|
||||
* events against the assembly-published 'webPlugins' context key.
|
||||
* Webserver invariant companion: the boot-graph consistency audit — every
|
||||
* fetch-arrival graph row must resolve a clientPath, checked on fiber
|
||||
* lifecycle events against the assembly-published 'webPlugins' context key.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@@ -9,7 +9,7 @@ import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as WebserverInvariant from '../src/invariant.ts'
|
||||
|
||||
interface RegistryStub {
|
||||
snapshot(): { id: string; url: string }[]
|
||||
graph(): { entries: { id: string; url: string }[] }
|
||||
clientPath(id: string): string | undefined
|
||||
}
|
||||
|
||||
@@ -33,18 +33,18 @@ describe('webserver manifest invariant', () => {
|
||||
expect(() => { trigger(bare) }).not.toThrow() // no 'webPlugins' key published
|
||||
|
||||
const consistent = await setup({
|
||||
snapshot: () => [{ id: 'p1', url: '/plugins/p1/client.js' }],
|
||||
clientPath: () => '/tmp/p1/lib/client.js',
|
||||
graph: () => ({ entries: [{ id: 'p1', url: '/plugins/p1/client.js?rev=abc' }] }),
|
||||
clientPath: id => id === 'p1' ? '/tmp/p1/lib/client.js' : undefined,
|
||||
})
|
||||
expect(() => { trigger(consistent) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('throws on a manifest row whose bundle path no longer resolves', async () => {
|
||||
it('throws on a graph row whose bundle path no longer resolves', async () => {
|
||||
const ctx = await setup({
|
||||
snapshot: () => [{ id: 'ghost', url: '/plugins/ghost/client.js' }],
|
||||
graph: () => ({ entries: [{ id: 'ghost', url: '/plugins/ghost/client.js?rev=abc' }] }),
|
||||
clientPath: () => undefined,
|
||||
})
|
||||
expect(() => { trigger(ctx) })
|
||||
.toThrow(/manifest row "ghost".*resolves no client bundle path/)
|
||||
.toThrow(/graph row "ghost".*resolves no client bundle path/)
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createHostWebPluginRegistry, injectBootManifest } from '../src/index.ts'
|
||||
import type { LoaderEntryView, WebPluginRegistryDeps } from '../src/index.ts'
|
||||
|
||||
@@ -25,6 +25,7 @@ interface Fixture {
|
||||
entries: LoaderEntryView[]
|
||||
errors: Error[]
|
||||
ctx: Context
|
||||
root: string
|
||||
}
|
||||
|
||||
function makeDeps(
|
||||
@@ -48,32 +49,30 @@ function makeDeps(
|
||||
},
|
||||
onError: err => void errors.push(err),
|
||||
}
|
||||
return { deps, entries, errors, ctx }
|
||||
return { deps, entries, errors, ctx, root }
|
||||
}
|
||||
|
||||
describe('createHostWebPluginRegistry', () => {
|
||||
it('collects loaded web-declared plugins with url/inject/immediately and client paths', () => {
|
||||
it('discovers dshClient rows with rev-stamped urls, manifest inject edges, and the declared immediately mark', () => {
|
||||
const { deps } = makeDeps([
|
||||
{ name: '@deepseek-ai/dsh-client-connection', pkg: webDecl({ immediately: true }) },
|
||||
{ name: '@deepseek-ai/dsh-client-ui-layout', pkg: webDecl({ inject: ['@deepseek-ai/dsh-client-runtime'] }) },
|
||||
{ name: '@deepseek-ai/dsh-agent', pkg: { exports: { '.': './lib/index.js' } } }, // no dshClient: skipped
|
||||
])
|
||||
const registry = createHostWebPluginRegistry(deps)
|
||||
const rows = registry.snapshot()
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
id: '@deepseek-ai/dsh-client-connection',
|
||||
url: '/plugins/@deepseek-ai/dsh-client-connection/client.js',
|
||||
inject: [],
|
||||
immediately: true,
|
||||
},
|
||||
{
|
||||
id: '@deepseek-ai/dsh-client-ui-layout',
|
||||
url: '/plugins/@deepseek-ai/dsh-client-ui-layout/client.js',
|
||||
inject: ['@deepseek-ai/dsh-client-runtime'],
|
||||
},
|
||||
])
|
||||
expect(registry.clientPath('@deepseek-ai/dsh-client-connection')).toMatch(/lib[/\\]client\.js$/)
|
||||
const graph = registry.graph()
|
||||
expect(graph.rev).toMatch(/^[0-9a-f]{12}$/)
|
||||
const connection = graph.entries[0]
|
||||
expect(connection?.id).toBe('@deepseek-ai/dsh-client-connection')
|
||||
expect(connection?.rev).toMatch(/^[0-9a-f]{12}$/)
|
||||
expect(connection?.url).toBe(`/plugins/@deepseek-ai/dsh-client-connection/client.js?rev=${connection?.rev ?? ''}`)
|
||||
expect(connection?.immediately).toBe(true)
|
||||
const layout = graph.entries[1]
|
||||
expect(layout?.id).toBe('@deepseek-ai/dsh-client-ui-layout')
|
||||
expect(layout?.inject).toEqual(['@deepseek-ai/dsh-client-runtime'])
|
||||
expect(layout?.immediately).toBeUndefined()
|
||||
expect(graph.entries).toHaveLength(2)
|
||||
expect(registry.clientPath('@deepseek-ai/dsh-client-ui-layout')).toMatch(/lib[/\\]client\.js$/)
|
||||
expect(registry.clientPath('@deepseek-ai/dsh-agent')).toBeUndefined()
|
||||
registry.dispose()
|
||||
})
|
||||
@@ -85,7 +84,7 @@ describe('createHostWebPluginRegistry', () => {
|
||||
{ name: 'electron-only', pkg: { dshClient: { platform: 'electron' }, exports: { './client': './lib/client.js' } } },
|
||||
])
|
||||
const registry = createHostWebPluginRegistry(deps)
|
||||
expect(registry.snapshot()).toEqual([])
|
||||
expect(registry.graph().entries).toEqual([])
|
||||
registry.dispose()
|
||||
})
|
||||
|
||||
@@ -96,6 +95,11 @@ describe('createHostWebPluginRegistry', () => {
|
||||
expect(() => createHostWebPluginRegistry(deps)).toThrow(/declares dshClient but exports no/)
|
||||
})
|
||||
|
||||
it('fails loud at build time on a registered bundle that is not built (rev hashing reads the file)', () => {
|
||||
const { deps } = makeDeps([{ name: 'unbuilt', pkg: webDecl(), withBundle: false }])
|
||||
expect(() => createHostWebPluginRegistry(deps)).toThrow(/ENOENT/)
|
||||
})
|
||||
|
||||
it('fails loud on malformed declaration fields', () => {
|
||||
for (const dshClient of [42, { platform: 7 }, { platform: 'web', inject: 'nope' }, { platform: 'web', immediately: 'yes' }]) {
|
||||
const { deps } = makeDeps([{ name: 'bad', pkg: { dshClient, exports: { './client': './lib/client.js' } } }])
|
||||
@@ -103,26 +107,74 @@ describe('createHostWebPluginRegistry', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('rescans on internal/plugin (debounced) and keeps the old table when a rescan fails', async () => {
|
||||
it('rebuilt(id) re-hashes the bundle, updates the row and graph rev, and keeps the immediately mark', () => {
|
||||
const { deps, root } = makeDeps([{ name: 'hot', pkg: webDecl({ immediately: true }) }])
|
||||
const registry = createHostWebPluginRegistry(deps)
|
||||
const before = registry.graph()
|
||||
const beforeRow = before.entries.find(e => e.id === 'hot')
|
||||
writeFileSync(join(root, 'hot', 'lib', 'client.js'), '// rebuilt bundle contents')
|
||||
const rev = registry.rebuilt('hot')
|
||||
expect(rev).toMatch(/^[0-9a-f]{12}$/)
|
||||
expect(rev).not.toBe(beforeRow?.rev)
|
||||
const after = registry.graph()
|
||||
const afterRow = after.entries.find(e => e.id === 'hot')
|
||||
expect(afterRow?.rev).toBe(rev)
|
||||
expect(afterRow?.url).toBe(`/plugins/hot/client.js?rev=${rev ?? ''}`)
|
||||
expect(afterRow?.immediately).toBe(true)
|
||||
expect(after.rev).not.toBe(before.rev)
|
||||
// Unknown ids are not rebuildable.
|
||||
expect(registry.rebuilt('nope')).toBeUndefined()
|
||||
registry.dispose()
|
||||
})
|
||||
|
||||
it('watch mode: a bundle content change re-hashes the row and notifies onRebuilt; dispose stops the watch', async () => {
|
||||
const { deps, root } = makeDeps([{ name: 'watched', pkg: webDecl() }])
|
||||
deps.watch = { intervalMs: 20 }
|
||||
const registry = createHostWebPluginRegistry(deps)
|
||||
const before = registry.graph().entries[0]?.rev
|
||||
const rebuilds: { id: string; rev: string }[] = []
|
||||
registry.onRebuilt((id, rev) => rebuilds.push({ id, rev }))
|
||||
|
||||
writeFileSync(join(root, 'watched', 'lib', 'client.js'), '// new bundle contents')
|
||||
await vi.waitFor(() => { expect(rebuilds).toHaveLength(1) }, { timeout: 5000 })
|
||||
expect(rebuilds[0]?.id).toBe('watched')
|
||||
expect(rebuilds[0]?.rev).not.toBe(before)
|
||||
expect(registry.graph().entries[0]?.rev).toBe(rebuilds[0]?.rev)
|
||||
|
||||
registry.dispose()
|
||||
writeFileSync(join(root, 'watched', 'lib', 'client.js'), '// post-dispose contents')
|
||||
await new Promise((resolve) => { setTimeout(resolve, 100) })
|
||||
expect(rebuilds).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rejects a non-positive or non-integer watch interval at build time', () => {
|
||||
for (const intervalMs of [0, -5, 1.5]) {
|
||||
const { deps } = makeDeps([{ name: 'p', pkg: webDecl() }])
|
||||
deps.watch = { intervalMs }
|
||||
expect(() => createHostWebPluginRegistry(deps)).toThrow(/watch\.intervalMs/)
|
||||
}
|
||||
})
|
||||
|
||||
it('rescans on internal/plugin (debounced) and keeps the old graph when a rescan fails', async () => {
|
||||
const { deps, entries, errors, ctx } = makeDeps([
|
||||
{ name: 'late-loader', pkg: webDecl(), loaded: false },
|
||||
])
|
||||
const registry = createHostWebPluginRegistry(deps)
|
||||
expect(registry.snapshot()).toEqual([])
|
||||
expect(registry.graph().entries).toEqual([])
|
||||
|
||||
// Entry finishes loading; a fiber lifecycle event triggers the debounced rescan.
|
||||
;(entries[0] as { fiber?: unknown }).fiber = {}
|
||||
ctx.emit('internal/plugin', ctx.fiber)
|
||||
ctx.emit('internal/plugin', ctx.fiber) // debounce: two emissions, one rescan
|
||||
await Promise.resolve()
|
||||
expect(registry.snapshot().map(row => row.id)).toEqual(['late-loader'])
|
||||
expect(registry.graph().entries.map(row => row.id)).toEqual(['late-loader'])
|
||||
|
||||
// A failing rescan reports the error and keeps serving the previous table.
|
||||
// A failing rescan reports the error and keeps serving the previous graph.
|
||||
entries.push({ options: { name: 'ghost' }, fiber: {}, disabled: false })
|
||||
ctx.emit('internal/plugin', ctx.fiber)
|
||||
await Promise.resolve()
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(registry.snapshot().map(row => row.id)).toEqual(['late-loader'])
|
||||
expect(registry.graph().entries.map(row => row.id)).toEqual(['late-loader'])
|
||||
|
||||
// After dispose, further fiber events no longer rescan.
|
||||
registry.dispose()
|
||||
@@ -134,16 +186,19 @@ describe('createHostWebPluginRegistry', () => {
|
||||
})
|
||||
|
||||
describe('injectBootManifest', () => {
|
||||
it('injects the manifest as the first script inside <head> and escapes </script> breakouts', () => {
|
||||
it('injects the graph as the first script inside <head> and escapes </script> breakouts', () => {
|
||||
const html = '<html><head><script src="app.js"></script></head><body></body></html>'
|
||||
const out = injectBootManifest(html, [{ id: 'x</script><script>alert(1)', url: '/plugins/x/client.js', inject: [] }])
|
||||
const out = injectBootManifest(html, {
|
||||
rev: 'r1',
|
||||
entries: [{ id: 'x</script><script>alert(1)', url: '/plugins/x/client.js?rev=r2', rev: 'r2' }],
|
||||
})
|
||||
expect(out.indexOf('window.__DSH_BOOT__')).toBeLessThan(out.indexOf('app.js'))
|
||||
expect(out).not.toContain('</script><script>alert(1)')
|
||||
expect(out).toContain('\\u003c/script')
|
||||
})
|
||||
|
||||
it('prepends when the page has no <head>', () => {
|
||||
const out = injectBootManifest('<body>x</body>', [])
|
||||
const out = injectBootManifest('<body>x</body>', { rev: 'r0', entries: [] })
|
||||
expect(out.startsWith('<script>window.__DSH_BOOT__')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -181,7 +236,7 @@ describe('clientExportOf shapes (through the registry build)', () => {
|
||||
entries.push({ options: { name: 'dup-entry' }, fiber: {}, disabled: false })
|
||||
void first
|
||||
const registry = createHostWebPluginRegistry(deps)
|
||||
expect(registry.snapshot().filter(r => r.id === 'dup-entry')).toHaveLength(1)
|
||||
expect(registry.graph().entries.filter(r => r.id === 'dup-entry')).toHaveLength(1)
|
||||
registry.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -179,18 +179,34 @@ describe.skipIf(process.platform === 'win32')('static serving', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injection + bundle endpoint)', () => {
|
||||
const rows = [
|
||||
{ id: '@deepseek-ai/dsh-client-connection', url: '/plugins/@deepseek-ai/dsh-client-connection/client.js', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-layout', url: '/plugins/@deepseek-ai/dsh-client-ui-layout/client.js', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
||||
]
|
||||
describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injection + bundle endpoint + events channel)', () => {
|
||||
const FETCH_ID = '@deepseek-ai/dsh-client-ui-layout'
|
||||
const graphValue = {
|
||||
rev: 'graphrev00001',
|
||||
entries: [
|
||||
{ id: '@deepseek-ai/dsh-client-connection', url: '/plugins/@deepseek-ai/dsh-client-connection/client.js?rev=eeee2222ffff', rev: 'eeee2222ffff', immediately: true },
|
||||
{ id: FETCH_ID, url: `/plugins/${FETCH_ID}/client.js?rev=aaaa0000bbbb`, rev: 'aaaa0000bbbb', inject: [] },
|
||||
],
|
||||
}
|
||||
|
||||
async function bootWithPlugins(): Promise<string> {
|
||||
/** Captures the server's onRebuilt subscription so tests can fire registry notifications by hand. */
|
||||
interface RebuiltHarness {
|
||||
notify: (id: string, rev: string) => void
|
||||
unsubscribed: boolean
|
||||
}
|
||||
|
||||
async function bootWithPlugins(harness?: RebuiltHarness): Promise<string> {
|
||||
const { distIndex, distRoot } = makeDist()
|
||||
writeFileSync(join(distRoot, 'bundle.js'), 'window.DSHClientProxy.loadPlugin({})')
|
||||
const webPlugins = {
|
||||
snapshot: () => rows,
|
||||
clientPath: (id: string) => id === rows[0]?.id ? join(distRoot, 'bundle.js') : undefined,
|
||||
graph: () => graphValue,
|
||||
clientPath: (id: string) => id === FETCH_ID ? join(distRoot, 'bundle.js') : undefined,
|
||||
onRebuilt: (listener: (id: string, rev: string) => void) => {
|
||||
if (harness !== undefined) harness.notify = listener
|
||||
return () => {
|
||||
if (harness !== undefined) harness.unsubscribed = true
|
||||
}
|
||||
},
|
||||
}
|
||||
server = await startWebServer(
|
||||
{ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined,
|
||||
@@ -198,12 +214,12 @@ describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injecti
|
||||
return `http://127.0.0.1:${String(server.port)}`
|
||||
}
|
||||
|
||||
it('injects window.__DSH_BOOT__ into / and SPA fallbacks; asset requests stay verbatim', async () => {
|
||||
it('injects the window.__DSH_BOOT__ graph into / and SPA fallbacks; asset requests stay verbatim', async () => {
|
||||
const base = await bootWithPlugins()
|
||||
const index = await (await fetch(`${base}/`)).text()
|
||||
expect(index).toContain('window.__DSH_BOOT__')
|
||||
const manifest = /window\.__DSH_BOOT__ = (.*?)<\/script>/.exec(index)?.[1]
|
||||
expect(JSON.parse(manifest ?? '')).toEqual({ plugins: rows })
|
||||
expect(JSON.parse(manifest ?? '')).toEqual(graphValue)
|
||||
|
||||
const fallback = await (await fetch(`${base}/routes/deep/link`)).text()
|
||||
expect(fallback).toContain('window.__DSH_BOOT__')
|
||||
@@ -213,11 +229,12 @@ describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injecti
|
||||
expect(await (await fetch(`${base}/app.js`)).text()).toBe('console.log(1)')
|
||||
})
|
||||
|
||||
it('serves registered client bundles and 404s unknown ids (no SPA fallback)', async () => {
|
||||
it('serves registered client bundles with no-cache (rev query ignored) and 404s unknown ids (no SPA fallback)', async () => {
|
||||
const base = await bootWithPlugins()
|
||||
const bundle = await fetch(`${base}/plugins/@deepseek-ai/dsh-client-connection/client.js`)
|
||||
const bundle = await fetch(`${base}/plugins/${FETCH_ID}/client.js?rev=whatever`)
|
||||
expect(bundle.status).toBe(200)
|
||||
expect(bundle.headers.get('content-type')).toBe('text/javascript; charset=utf-8')
|
||||
expect(bundle.headers.get('cache-control')).toBe('no-cache')
|
||||
expect(await bundle.text()).toContain('DSHClientProxy')
|
||||
|
||||
expect((await fetch(`${base}/plugins/unknown/client.js`)).status).toBe(404)
|
||||
@@ -226,23 +243,59 @@ describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injecti
|
||||
it('404s a registered id whose bundle file is unreadable (unbuilt dist must fail loud, not fall back to HTML)', async () => {
|
||||
const { distIndex } = makeDist()
|
||||
const webPlugins = {
|
||||
snapshot: () => rows,
|
||||
graph: () => graphValue,
|
||||
clientPath: () => '/nonexistent/lib/client.js',
|
||||
onRebuilt: () => () => undefined,
|
||||
}
|
||||
server = await startWebServer(
|
||||
{ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined,
|
||||
)
|
||||
const res = await fetch(`http://127.0.0.1:${String(server.port)}/plugins/@deepseek-ai/dsh-client-connection/client.js`)
|
||||
const res = await fetch(`http://127.0.0.1:${String(server.port)}/plugins/${FETCH_ID}/client.js`)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('keeps both surfaces off without the webPlugins option', async () => {
|
||||
it('keeps all plugin surfaces off without the webPlugins option', async () => {
|
||||
const base = await boot()
|
||||
expect(await (await fetch(`${base}/`)).text()).toBe('<html>INDEX</html>')
|
||||
// No plugin route: falls through to static SPA fallback semantics.
|
||||
// No plugin routes: fall through to static SPA fallback semantics.
|
||||
const res = await fetch(`${base}/plugins/x/client.js`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.text()).toBe('<html>INDEX</html>')
|
||||
const events = await fetch(`${base}/plugins/events`)
|
||||
expect(await events.text()).toBe('<html>INDEX</html>')
|
||||
})
|
||||
|
||||
it('GET /plugins/events opens SSE with the current graph frame; a registry rebuild notification broadcasts', async () => {
|
||||
const harness: RebuiltHarness = { notify: () => { throw new Error('onRebuilt never subscribed') }, unsubscribed: false }
|
||||
const base = await bootWithPlugins(harness)
|
||||
const events = await fetch(`${base}/plugins/events`)
|
||||
expect(events.status).toBe(200)
|
||||
expect(events.headers.get('content-type')).toBe('text/event-stream')
|
||||
const reader = events.body?.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
async function readUntil(marker: string): Promise<void> {
|
||||
while (!buffer.includes(marker)) {
|
||||
const chunk = await reader?.read()
|
||||
if (chunk?.done !== false) throw new Error('SSE stream ended early')
|
||||
buffer += decoder.decode(chunk.value, { stream: true })
|
||||
}
|
||||
}
|
||||
await readUntil('"type":"graph"')
|
||||
expect(buffer).toContain(': connected')
|
||||
const graphLine = /data: (.*)\n\n/.exec(buffer)?.[1]
|
||||
expect(JSON.parse(graphLine ?? '')).toEqual({ type: 'graph', graph: graphValue })
|
||||
|
||||
// The registry's bundle watch observed a rebuild: the server relays it as an SSE frame.
|
||||
harness.notify(FETCH_ID, 'cccc1111dddd')
|
||||
await readUntil('"type":"rebuilt"')
|
||||
expect(buffer).toContain(JSON.stringify({ type: 'rebuilt', id: FETCH_ID, rev: 'cccc1111dddd' }))
|
||||
await reader?.cancel()
|
||||
|
||||
// Shutdown unsubscribes the relay (no broadcast into a closed channel).
|
||||
await server?.close()
|
||||
server = undefined
|
||||
expect(harness.unsubscribed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Generated
+95
-61
@@ -101,6 +101,36 @@ importers:
|
||||
'@deepseek-ai/dsh-app-boot':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/ui/app-boot
|
||||
'@deepseek-ai/dsh-client-connection':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/client/connection
|
||||
'@deepseek-ai/dsh-client-hmr':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/client/hmr
|
||||
'@deepseek-ai/dsh-client-i18n':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/client/i18n
|
||||
'@deepseek-ai/dsh-client-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/client/runtime
|
||||
'@deepseek-ai/dsh-client-ui-conversation':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/client/ui-conversation
|
||||
'@deepseek-ai/dsh-client-ui-layout':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/client/ui-layout
|
||||
'@deepseek-ai/dsh-client-ui-question':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/client/ui-question
|
||||
'@deepseek-ai/dsh-client-ui-sidebar':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/client/ui-sidebar
|
||||
'@deepseek-ai/dsh-client-ui-theme':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/client/ui-theme
|
||||
'@deepseek-ai/dsh-client-ui-trajectory':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/client/ui-trajectory
|
||||
'@deepseek-ai/dsh-frontend':
|
||||
specifier: workspace:^
|
||||
version: link:../web
|
||||
@@ -132,9 +162,9 @@ importers:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1(react@18.3.1)
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-client-runtime':
|
||||
'@deepseek-ai/dsh-client-modules':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/client/runtime
|
||||
version: link:../../packages/client/modules
|
||||
'@deepseek-ai/dsh-client-ui-primitives':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/client/ui-primitives
|
||||
@@ -509,6 +539,21 @@ importers:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
|
||||
packages/client/hmr:
|
||||
devDependencies:
|
||||
'@cordisjs/plugin-loader':
|
||||
specifier: workspace:^
|
||||
version: link:../../../vendor/loader
|
||||
'@deepseek-ai/dsh-client-modules':
|
||||
specifier: workspace:^
|
||||
version: link:../modules
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../support/invariants
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader)
|
||||
|
||||
packages/client/i18n:
|
||||
dependencies:
|
||||
'@deepseek-ai/dsh-client-runtime':
|
||||
@@ -522,6 +567,15 @@ importers:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
|
||||
packages/client/modules:
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../support/invariants
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
|
||||
packages/client/runtime:
|
||||
dependencies:
|
||||
'@deepseek-ai/dsh-client-connection':
|
||||
@@ -530,6 +584,9 @@ importers:
|
||||
'@deepseek-ai/dsh-client-ui-slots':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-slots
|
||||
'@deepseek-ai/dsh-host-apiproxy':
|
||||
specifier: workspace:^
|
||||
version: link:../../host/apiproxy
|
||||
'@deepseek-ai/dsh-llm':
|
||||
specifier: workspace:^
|
||||
version: link:../../llm/llm
|
||||
@@ -558,6 +615,10 @@ importers:
|
||||
|
||||
packages/client/ui-conversation:
|
||||
dependencies:
|
||||
clsx:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.1
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-client-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../runtime
|
||||
@@ -570,13 +631,6 @@ importers:
|
||||
'@deepseek-ai/dsh-client-ui-slots':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-slots
|
||||
clsx:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.1
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../support/invariants
|
||||
@@ -586,19 +640,18 @@ importers:
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1
|
||||
|
||||
packages/client/ui-layout:
|
||||
dependencies:
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-client-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../runtime
|
||||
'@deepseek-ai/dsh-client-ui-slots':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-slots
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../support/invariants
|
||||
@@ -608,6 +661,9 @@ importers:
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1
|
||||
|
||||
packages/client/ui-primitives:
|
||||
dependencies:
|
||||
@@ -685,6 +741,10 @@ importers:
|
||||
|
||||
packages/client/ui-sidebar:
|
||||
dependencies:
|
||||
clsx:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.1
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-client-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../runtime
|
||||
@@ -697,13 +757,6 @@ importers:
|
||||
'@deepseek-ai/dsh-client-ui-slots':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-slots
|
||||
clsx:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.1
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../support/invariants
|
||||
@@ -713,6 +766,9 @@ importers:
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1
|
||||
|
||||
packages/client/ui-slots:
|
||||
devDependencies:
|
||||
@@ -736,17 +792,16 @@ importers:
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
|
||||
packages/client/ui-trajectory:
|
||||
dependencies:
|
||||
'@deepseek-ai/dsh-client-ui-conversation':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-conversation
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-client-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../runtime
|
||||
'@deepseek-ai/dsh-client-ui-conversation':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-conversation
|
||||
'@deepseek-ai/dsh-client-ui-slots':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-slots
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../support/invariants
|
||||
@@ -756,15 +811,15 @@ importers:
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1
|
||||
|
||||
packages/client/web:
|
||||
dependencies:
|
||||
'@deepseek-ai/dsh-client-connection':
|
||||
'@deepseek-ai/dsh-client-modules':
|
||||
specifier: workspace:^
|
||||
version: link:../connection
|
||||
'@deepseek-ai/dsh-client-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../runtime
|
||||
version: link:../modules
|
||||
'@deepseek-ai/dsh-client-ui-primitives':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-primitives
|
||||
@@ -784,6 +839,12 @@ importers:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1(react@18.3.1)
|
||||
devDependencies:
|
||||
'@cordisjs/plugin-loader':
|
||||
specifier: workspace:^
|
||||
version: link:../../../vendor/loader
|
||||
'@deepseek-ai/dsh-client-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../runtime
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../support/invariants
|
||||
@@ -795,7 +856,7 @@ importers:
|
||||
version: 18.3.7(@types/react@18.3.31)
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader)
|
||||
typescript:
|
||||
specifier: ^6.0.3
|
||||
version: 6.0.3
|
||||
@@ -2027,33 +2088,6 @@ importers:
|
||||
'@deepseek-ai/dsh-bash-local':
|
||||
specifier: workspace:^
|
||||
version: link:../../bash/bash-local
|
||||
'@deepseek-ai/dsh-client-connection':
|
||||
specifier: workspace:^
|
||||
version: link:../../client/connection
|
||||
'@deepseek-ai/dsh-client-i18n':
|
||||
specifier: workspace:^
|
||||
version: link:../../client/i18n
|
||||
'@deepseek-ai/dsh-client-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../../client/runtime
|
||||
'@deepseek-ai/dsh-client-ui-conversation':
|
||||
specifier: workspace:^
|
||||
version: link:../../client/ui-conversation
|
||||
'@deepseek-ai/dsh-client-ui-layout':
|
||||
specifier: workspace:^
|
||||
version: link:../../client/ui-layout
|
||||
'@deepseek-ai/dsh-client-ui-question':
|
||||
specifier: workspace:^
|
||||
version: link:../../client/ui-question
|
||||
'@deepseek-ai/dsh-client-ui-sidebar':
|
||||
specifier: workspace:^
|
||||
version: link:../../client/ui-sidebar
|
||||
'@deepseek-ai/dsh-client-ui-theme':
|
||||
specifier: workspace:^
|
||||
version: link:../../client/ui-theme
|
||||
'@deepseek-ai/dsh-client-ui-trajectory':
|
||||
specifier: workspace:^
|
||||
version: link:../../client/ui-trajectory
|
||||
'@deepseek-ai/dsh-compact-basic':
|
||||
specifier: workspace:^
|
||||
version: link:../../compact/compact-basic
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* Pins the client-bundle purity gate (tsdown preset resolveId classifier):
|
||||
* a bare-name import of a module-table package must rewrite to its /client
|
||||
* external form (inlining it duplicates runtime identity — the P0
|
||||
/* leak that is not an
|
||||
* inline-safe wire layer must fail the build loudly.
|
||||
* Pins the client-bundle purity gate (tsdown preset resolveId classifier),
|
||||
* the build-time mirror of the module-edge rules: platform module-table
|
||||
* entries stay external, inline-safe wire layers inline, and every other
|
||||
* @deepseek-ai value import — including a bare plugin-package name and a
|
||||
* cross-plugin /client subpath — must fail the build loudly (cross-plugin
|
||||
* collaboration goes through cordis services, never module imports).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CLIENT_EXTERNALS, clientBundle } from '../packages/client/tsdown.client.ts'
|
||||
@@ -23,22 +24,16 @@ function purityResolveId(): ResolveId {
|
||||
describe('client bundle purity gate', () => {
|
||||
const resolveId = purityResolveId()
|
||||
|
||||
it('leaves table entries and non-scoped specifiers alone', () => {
|
||||
it('leaves platform table entries and non-scoped specifiers alone', () => {
|
||||
expect(resolveId('@deepseek-ai/dsh-client-ui-slots')).toBeNull()
|
||||
expect(resolveId('@deepseek-ai/dsh-client-runtime/client')).toBeNull()
|
||||
expect(resolveId('@deepseek-ai/dsh-client-web-react')).toBeNull()
|
||||
expect(resolveId('@deepseek-ai/dsh-client-ui-primitives')).toBeNull()
|
||||
expect(resolveId('react')).toBeNull()
|
||||
expect(resolveId('zod')).toBeNull()
|
||||
})
|
||||
|
||||
it('rewrites a bare table-package name to its external /client form (duplicate-instance prevention)', () => {
|
||||
expect(resolveId('@deepseek-ai/dsh-client-connection')).toEqual({
|
||||
id: '@deepseek-ai/dsh-client-connection/client',
|
||||
external: true,
|
||||
})
|
||||
expect(resolveId('@deepseek-ai/dsh-client-ui-layout')).toEqual({
|
||||
id: '@deepseek-ai/dsh-client-ui-layout/client',
|
||||
external: true,
|
||||
})
|
||||
it('rejects retired table entries (web-react/store left the 8-entry seed)', () => {
|
||||
expect(() => resolveId('@deepseek-ai/dsh-client-web-react/store')).toThrow(/purity/)
|
||||
})
|
||||
|
||||
it('lets inline-safe wire layers inline', () => {
|
||||
@@ -52,9 +47,16 @@ describe('client bundle purity gate', () => {
|
||||
expect(() => resolveId('@deepseek-ai/dsh-client-web')).toThrow(/purity/)
|
||||
})
|
||||
|
||||
it('every /client external has no bare-name twin in the table (the rewrite assumption)', () => {
|
||||
for (const entry of CLIENT_EXTERNALS) {
|
||||
if (entry.endsWith('/client')) expect(CLIENT_EXTERNALS).not.toContain(entry.slice(0, -'/client'.length))
|
||||
}
|
||||
it('throws on cross-plugin value imports — bare plugin names and /client subpaths alike (the rewrite arm is gone)', () => {
|
||||
expect(() => resolveId('@deepseek-ai/dsh-client-connection')).toThrow(/purity/)
|
||||
expect(() => resolveId('@deepseek-ai/dsh-client-runtime')).toThrow(/purity/)
|
||||
expect(() => resolveId('@deepseek-ai/dsh-client-ui-layout/client')).toThrow(/purity/)
|
||||
})
|
||||
|
||||
it('carries exactly one documented temporary exemption: runtime/client (store engine pending rehoming)', () => {
|
||||
expect(resolveId('@deepseek-ai/dsh-client-runtime/client')).toBeNull()
|
||||
const dshClientChannels = CLIENT_EXTERNALS.filter(
|
||||
entry => entry.startsWith('@deepseek-ai/') && entry.endsWith('/client'))
|
||||
expect(dshClientChannels).toEqual(['@deepseek-ai/dsh-client-runtime/client'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Watch-build for client-plugin HMR: runs every dshClient plugin package
|
||||
* through the tsdown JS API in watch mode. Reload signaling is not this
|
||||
* script's business — the host webserver stat-polls the bundles it serves and
|
||||
* broadcasts `rebuilt` frames itself (`dsh web --dev`), so any process that
|
||||
* rewrites `lib/client.js` files triggers reloads; this script is merely the
|
||||
* convenient way to keep them all rebuilt on source change.
|
||||
*
|
||||
* Usage: `pnpm exec tsx scripts/dev-web.ts [--poll[=ms]]`. Requires the
|
||||
* packages' node halves built once (`tsc -b tsconfig.build.json`): the lib
|
||||
* config's entries are tsc output. `--poll` switches the source-file watcher
|
||||
* to polling (default 500ms): network mounts (weka) deliver no inotify
|
||||
* events, so native watching sees the initial build only and never a source
|
||||
* change.
|
||||
*
|
||||
* Each package keeps its own tsdown.config.ts untouched: this script layers
|
||||
* `watch` through API-level inline config (tsdown workspace mode fills inline
|
||||
* keys under each package's file config, and no package config defines it).
|
||||
*/
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { build } from 'tsdown'
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('..', import.meta.url))
|
||||
|
||||
/**
|
||||
* Discover the watch workspace by declaration: every packages/<group>/<name>
|
||||
* whose package.json carries `dshClient` with platform "web" is a client
|
||||
* plugin bundle emitter. Scanned once at startup — a package added while
|
||||
* watching means restarting this script.
|
||||
* @returns workspace-relative plugin package directories.
|
||||
*/
|
||||
function discoverPluginDirs(): string[] {
|
||||
const dirs: string[] = []
|
||||
for (const group of readdirSync(join(repoRoot, 'packages'), { withFileTypes: true })) {
|
||||
if (!group.isDirectory()) continue
|
||||
for (const pkg of readdirSync(join(repoRoot, 'packages', group.name), { withFileTypes: true })) {
|
||||
if (!pkg.isDirectory()) continue
|
||||
let manifest: { dshClient?: { platform?: unknown } }
|
||||
try {
|
||||
manifest = JSON.parse(
|
||||
readFileSync(join(repoRoot, 'packages', group.name, pkg.name, 'package.json'), 'utf8'),
|
||||
) as { dshClient?: { platform?: unknown } }
|
||||
} catch {
|
||||
continue // no package.json (support dirs, scratch): not a workspace package
|
||||
}
|
||||
if (manifest.dshClient?.platform === 'web') dirs.push(`packages/${group.name}/${pkg.name}`)
|
||||
}
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
const PLUGIN_DIRS = discoverPluginDirs()
|
||||
if (PLUGIN_DIRS.length === 0) {
|
||||
console.error('dev-web: no dshClient (platform "web") packages found under packages/')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const pollArg = args.find(a => a === '--poll' || a.startsWith('--poll='))
|
||||
if (args.some(a => a !== pollArg)) {
|
||||
console.error('dev-web: usage: tsx scripts/dev-web.ts [--poll[=ms]]')
|
||||
process.exit(1)
|
||||
}
|
||||
const pollInterval = pollArg === undefined ? undefined : Number(pollArg.split('=')[1] ?? '500')
|
||||
if (pollInterval !== undefined && (!Number.isInteger(pollInterval) || pollInterval <= 0)) {
|
||||
console.error(`dev-web: invalid --poll interval "${pollArg ?? ''}"`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await build({
|
||||
cwd: repoRoot,
|
||||
workspace: PLUGIN_DIRS,
|
||||
watch: true,
|
||||
// Rolldown watch options ride through inputOptions (tsdown has no watcher
|
||||
// tuning of its own); polling is opt-in for network mounts without inotify.
|
||||
...pollInterval !== undefined
|
||||
? { inputOptions: { watch: { watcher: { usePolling: true, pollInterval } } } }
|
||||
: {},
|
||||
})
|
||||
console.log(
|
||||
`dev-web: watching ${String(PLUGIN_DIRS.length)} dshClient plugin packages`
|
||||
+ `${pollInterval !== undefined ? ` (polling ${String(pollInterval)}ms)` : ''}:\n ${PLUGIN_DIRS.join('\n ')}`,
|
||||
)
|
||||
@@ -6,6 +6,7 @@
|
||||
".agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md",
|
||||
".agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md",
|
||||
".agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md",
|
||||
".agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md",
|
||||
".agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md",
|
||||
".agents/notes/implemented/process/2026-07-19-web-styling-system.md",
|
||||
"README.md",
|
||||
|
||||
@@ -45,6 +45,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/bash/bash-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-bash.' },
|
||||
'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' },
|
||||
'packages/code-runtime/code-runtime-worker': { kind: 'indirect', reason: 'The worker backend delegates model rendering to Code Mode in dsh-tools.' },
|
||||
'packages/client/hmr': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/modules': { kind: 'none', reason: 'Browser-side module-loading kernel machinery; registers no model surface.' },
|
||||
'packages/client/ui-slots': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/ui-primitives': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/web-react': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
|
||||
+2
-1
@@ -102,9 +102,10 @@
|
||||
"@deepseek-ai/dsh-client-ui-primitives": ["./packages/client/ui-primitives/src"],
|
||||
"@deepseek-ai/dsh-client-web-react": ["./packages/client/web-react/src"],
|
||||
"@deepseek-ai/dsh-client-connection": ["./packages/client/connection/src"],
|
||||
"@deepseek-ai/dsh-client-hmr": ["./packages/client/hmr/src"],
|
||||
"@deepseek-ai/dsh-client-modules": ["./packages/client/modules/src"],
|
||||
"@deepseek-ai/dsh-client-runtime": ["./packages/client/runtime/src"],
|
||||
"@deepseek-ai/dsh-client-runtime/client": ["./packages/client/runtime/src/client"],
|
||||
"@deepseek-ai/dsh-client-runtime/loader": ["./packages/client/runtime/src/client/loader"],
|
||||
"@deepseek-ai/dsh-client-ui-layout": ["./packages/client/ui-layout/src"],
|
||||
"@deepseek-ai/dsh-client-ui-sidebar": ["./packages/client/ui-sidebar/src"],
|
||||
"@deepseek-ai/dsh-client-ui-conversation": ["./packages/client/ui-conversation/src"],
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
{ "path": "./packages/client/ui-slots" },
|
||||
{ "path": "./packages/client/ui-primitives" },
|
||||
{ "path": "./packages/client/web-react" },
|
||||
{ "path": "./packages/client/modules" },
|
||||
{ "path": "./packages/client/hmr" },
|
||||
{ "path": "./packages/client/connection" },
|
||||
{ "path": "./packages/client/runtime" },
|
||||
{ "path": "./packages/client/ui-layout" },
|
||||
|
||||
@@ -104,6 +104,8 @@ export default defineConfig({
|
||||
'packages/client/ui-layout/src/*',
|
||||
'packages/client/web/src/*',
|
||||
'packages/host/webserver/src/*',
|
||||
'packages/client/modules/src/loader.ts',
|
||||
'packages/client/hmr/src/client/index.ts',
|
||||
...windowsUnsupportedPackages.map(path => `${path}/src/**/*.ts`),
|
||||
...windowsCoverageExclusions,
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user