feat(client-modules): prompt to reload when the host release changes
This commit is contained in:
1 parent
f12bf31c4a
commit
7c9e54b3ef
34 files changed
+703
-38
No files matched your search
@@ -6,8 +6,8 @@
|
||||
en: e256a29cc5bc2726
|
||||
zh: e181d6b1a6e5c647
|
||||
/client-modules/the-wire:
|
||||
en: 5734d296e8ea39d1
|
||||
zh: 27bedf34fc90d892
|
||||
en: 5f889c13ff035ebd
|
||||
zh: ed35e2bcc1ed8924
|
||||
/client-modules/the-scan:
|
||||
en: 91ce66fab388a8a2
|
||||
zh: 77428943582ae3f6
|
||||
|
||||
@@ -57,11 +57,23 @@ interface WebBootBatch {
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Host release identity; two releases are the same when both fields are equal. */
|
||||
interface WebBootRelease {
|
||||
/** Release version label. */
|
||||
version: string
|
||||
/** Source commit fixed in when the Host was built; absent when the build recorded none. */
|
||||
commit?: string
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** The composed client entry graph the host injects as `window.__DSH_BOOT__`. */
|
||||
interface WebBootGraph {
|
||||
/** Consistency anchor over the current entry and batch descriptors. */
|
||||
rev: string
|
||||
/** Host release identity; absent from a Host that publishes none. */
|
||||
release?: WebBootRelease
|
||||
/**
|
||||
* Composed entries in module-graph order — a dynamic package row precedes
|
||||
* rows whose `external` requests that package. Cordis activation order is
|
||||
@@ -75,6 +87,8 @@ interface WebBootGraph {
|
||||
|
||||
Initial publication and HMR derive each row's `rev` from the entry's mtime, ctime, and size, without hashing executable bytes. The same artifacts retain their revisions across Host restarts. The initial descriptors partition rows into bootstrap and application scheduling phases, and either phase may contain several descriptors. Their URLs contain only the ordered package-resource list and a revision derived from those row revisions; phase names do not enter the route. Graph composition preserves row order while greedily splitting before the map-form URL exceeds 3 KiB, without concatenating scripts or reading maps. The graph revision hashes the entry and batch descriptors. `immediately` marks the stage-one registration barrier; rows within one combo share its script transport, while separate combos load independently.
|
||||
|
||||
`release` pairs this package's version with the source commit the client build fixes into the built Host artifact, and stays outside the graph revision. A page applies graphs only from the release it last applied, starting with its boot release; a graph from any other release waits for the page's decision ([Host release changes](../../packages/client/modules/README.md#host-release-changes)).
|
||||
|
||||
## The scan
|
||||
|
||||
A package joins the table by declaring `dsh.client` (`platform: 'web'`, optional `inject` edges, optional `immediately`) in its package.json and exporting its built bundle at `exports["./client"]`. Each live row resolves from its own Loader specifier and owning-tree `baseUrl`, through the same `loader.internal.resolveSync` implementation that imports its Host face when available. The nearest owning package manifest supplies the browser module id, so relative source and built overlays retain the package identity. Distinct active Loader sources resolving to one package name fail composition; after one source unloads, the surviving source supplies the row without a fiber restart.
|
||||
|
||||
@@ -57,11 +57,23 @@ interface WebBootBatch {
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Host release identity; two releases are the same when both fields are equal. */
|
||||
interface WebBootRelease {
|
||||
/** Release version label. */
|
||||
version: string
|
||||
/** Source commit fixed in when the Host was built; absent when the build recorded none. */
|
||||
commit?: string
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** The composed client entry graph the host injects as `window.__DSH_BOOT__`. */
|
||||
interface WebBootGraph {
|
||||
/** Consistency anchor over the current entry and batch descriptors. */
|
||||
rev: string
|
||||
/** Host release identity; absent from a Host that publishes none. */
|
||||
release?: WebBootRelease
|
||||
/**
|
||||
* Composed entries in module-graph order — a dynamic package row precedes
|
||||
* rows whose `external` requests that package. Cordis activation order is
|
||||
@@ -75,6 +87,8 @@ interface WebBootGraph {
|
||||
|
||||
首次发布与 HMR 都从入口的 mtime、ctime 和大小派生每个 row 的 `rev`,不对可执行字节求哈希。相同产物会跨 Host 重启保持 revision。初始 descriptor 把 row 划入 bootstrap 与 application 两个调度阶段,每个阶段都可以包含多条 descriptor。URL 只含有序 package 资源列表和从这些 row revision 派生的 revision,阶段名不会进入路由。图组合保持 row 顺序,并在 map 形式 URL 超过 3 KiB 前贪心切分,不拼接脚本,也不读取 map。图 revision 对 entry 与 batch descriptor 求哈希。`immediately` 标记第一阶段的 registration barrier;同一 combo 中的 row 共享脚本传输,不同 combo 则独立加载。
|
||||
|
||||
`release` 把本包版本与 client 构建固化进 Host 构建产物的源码 commit 配成一对,不计入图 revision。页面只应用其上次应用的 release 的图,初始为页面启动时的 release;来自其他 release 的图等待页面决定([Host release 变化](../../packages/client/modules/README.zh.md#host-release-changes))。
|
||||
|
||||
## 扫描
|
||||
|
||||
包加入这张表的方式,是在自己的 package.json 中声明 `dsh.client`(`platform: 'web'`、可选的 `inject` 边、可选的 `immediately`),并在 `exports["./client"]` 导出构建好的 bundle。每个 live row 都从自己的 Loader specifier 与所属 tree `baseUrl` 解析;若 `loader.internal.resolveSync` 可用,则使用 Host face import 所用的同一个实现。最近归属的 package manifest 提供浏览器模块 id,因此相对 source 与 built overlay 仍保留包身份。若不同的 active Loader source 解析到同一包名,组合会失败;一个来源卸载后,仍存活的来源无需重启 fiber 即可提供该 row。
|
||||
|
||||
@@ -73,7 +73,7 @@ Npm sections describe installation and development relationships; each build fac
|
||||
|
||||
## Build-time browser environment
|
||||
|
||||
Client business code may statically read `process.env.DSH_CLIENT_*`; every referenced value is public artifact content. The shared build-environment helper gives Vite and dynamic tsdown bundles the same build-process values, resolves unset names to `undefined`, and exposes no dynamic lookup or enumeration. A complete root build records the exact public values and a digest of all client artifacts; release and built-artifact consumers reject a missing or stale record. Use runtime configuration for choices that must change after build.
|
||||
Client business code may statically read `process.env.DSH_CLIENT_*`; every referenced value is public artifact content. The shared build-environment helper gives Vite and dynamic tsdown bundles the same build-process values, resolves unset names to `undefined`, and exposes no dynamic lookup or enumeration. The Node half of a client package receives the same fixed values while keeping its runtime `process.env`; Host source launches read them from the environment `pnpm run dev:web` prepares. A complete root build records the exact public values and a digest of all client artifacts; release and built-artifact consumers reject a missing or stale record. Use runtime configuration for choices that must change after build.
|
||||
|
||||
## Shared modules and the module graph
|
||||
|
||||
|
||||
@@ -25,7 +25,10 @@
|
||||
zh: 94dd7d9abbdbccf3
|
||||
/deepseek-ai-dsh-client-modules/use-this-package/live-plugin-composition:
|
||||
en: 45740a56e931d094
|
||||
zh: b3362af77c804459
|
||||
zh: fa44cf77c86019af
|
||||
/deepseek-ai-dsh-client-modules/use-this-package/host-release-changes:
|
||||
en: e83691968fb417d0
|
||||
zh: 060a859360cc064f
|
||||
/deepseek-ai-dsh-client-modules/use-this-package/sharing-modules:
|
||||
en: 1778b5a63ecde5e8
|
||||
zh: 537b4f8c755af8ad
|
||||
@@ -48,8 +51,8 @@
|
||||
en: 253d9f265cd83ed0
|
||||
zh: 9d0f8df84d9d3ac9
|
||||
/deepseek-ai-dsh-client-modules/understand-the-implementation/entry-ownership:
|
||||
en: ae59769034d692fa
|
||||
zh: f71914d6f39b033a
|
||||
en: 208e528dc3e0ed92
|
||||
zh: 82b0ef93f282eacd
|
||||
/deepseek-ai-dsh-client-modules/understand-the-implementation/source-map:
|
||||
en: fabe9db8c44e2d90
|
||||
zh: 89ca94a8161f57ed
|
||||
@@ -63,8 +66,8 @@
|
||||
en: ca75c51c89c2c9b0
|
||||
zh: de54f3467ef3b9be
|
||||
/deepseek-ai-dsh-client-modules/known-limitations-and-deferred-work:
|
||||
en: d0908712f037c479
|
||||
zh: 14034e5df3c34e7f
|
||||
en: 764a83c20b8fe401
|
||||
zh: 368b553a56e46dfc
|
||||
/deepseek-ai-dsh-client-modules/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: 3bb0c20c93f16f98
|
||||
@@ -41,6 +41,10 @@ The application combo scripts carry only each plugin's `client.js` entry and reg
|
||||
|
||||
An open Web page follows the Host's complete module graph through the HMR transport. Enabling an ordinary plugin adds its Loader entry; disabling it removes the entry and waits for its asynchronous effects before evicting unused modules and styles. Re-enabling loads one instance with its styles. Other Loader contributors and shared modules still needed by active entries remain loaded. Settings → Plugins → Plugin list shows page-local synchronization failures and offers retry without changing Host enablement.
|
||||
|
||||
### Host release changes
|
||||
|
||||
Every graph carries the Host release identity as `release`: this package's `version`, which releases share with the CLI, and the source `commit` that the client build fixes into the built Host artifact. Two releases are equal only when both fields are. A page applies graphs only from the release it last applied, which starts as the release it booted from. After the page reconnects to a Host with another release, it holds that release's latest graph, ignores its rebuilt frames, publishes `ctx.modules.entries.releaseChange` with the page's boot release, the last applied release, and the new one, and keeps its current plugins running. `acceptRelease()` makes the held release current and applies its graph; reloading the page boots the new release instead. A graph from the current release withdraws the pending change. The current release lives only in page memory.
|
||||
|
||||
### Sharing modules
|
||||
|
||||
The shell seeds a frozen module table ([`PLATFORM_MODULES`](../web/README.md#the-shared-module-table)); every dynamic bundle resolves its externals against exactly that baseline. `dsh.client.external` adds only exact non-baseline requests, each answered by the dynamic package row it names or an exact static-table key. Type-only imports are erased and create no request. Composition rejects malformed requests, missing suppliers, self-requests, and synchronous request cycles.
|
||||
@@ -81,7 +85,7 @@ The host contributes structured index rows that inject, into `<head>`: the `wind
|
||||
|
||||
### Entry ownership
|
||||
|
||||
`ClientEntries` records the entries created during boot and serializes full-graph updates, retries and code reloads over the same Loader. A local generation prevents an older download from mounting after its desired entry or code changes; snapshots of the same targets share the pending load. New arrivals use single-resource URLs, never startup batches that could register existing factories twice. Factories retain their artifact revision before an entry exists; graph updates discard stale unowned factories, their styles and failed arrival targets before importing consumers. Cleanup retains declared and observed transitive module requests from every remaining Loader entry. Its observable status has no runtime library import because the modules bootstrap materializes before platform seeds are available.
|
||||
`ClientEntries` records the entries created during boot and serializes full-graph updates, retries and code reloads over the same Loader. A local generation prevents an older download from mounting after its desired entry or code changes; snapshots of the same targets share the pending load. A graph from another release stays outside the queue: retries keep reconciling the last applied graph, and rebuilt frames are ignored until the page accepts that release. New arrivals use single-resource URLs, never startup batches that could register existing factories twice. Factories retain their artifact revision before an entry exists; graph updates discard stale unowned factories, their styles and failed arrival targets before importing consumers. Cleanup retains declared and observed transitive module requests from every remaining Loader entry. Its observable status has no runtime library import because the modules bootstrap materializes before platform seeds are available.
|
||||
|
||||
### Source map
|
||||
|
||||
@@ -129,6 +133,8 @@ These limits define what the module system does not do. They are current package
|
||||
|
||||
- **Metadata-based revisions** — revisions identify filesystem generations, not content equality. Metadata-only changes can reload a plugin; changes invisible in mtime, ctime, and size cannot be distinguished.
|
||||
- **Flat module graph by design** — every bundle is one module node whose edges point only at table leaves; the interface (`loadCache`/`edges`/`invalidate`) already supports a general module graph, so the externalization granularity can change without an interface change.
|
||||
- **Host release detection rides the graph stream** — a page learns another Host release only from a graph frame. When a restarting Host answers `/plugins/events` with a non-200 status before registering the route, the browser closes that stream for good, and the page neither updates plugins nor detects the release change until it reloads.
|
||||
- **Source launch has no build step for the Host half** — a Host started from TypeScript source reads the commit that `pnpm run dev:web` places in its environment; a source launch without it publishes the version alone.
|
||||
- **Bootstrap and code replacement limits** — the page retains its modules bootstrap and static platform identities. Removing or replacing the bootstrap requires a page reload; live replacement requests report a page-local error while retaining its fiber and exports; replacing package code and all existing consumers is outside ordinary enable/disable synchronization.
|
||||
- **Lazy delivery retains requested bodies** — the Host holds each bundle and lazy response plan; a script or map body remains cached after its first `GET`, and HMR additionally retains one prior startup generation. Memory grows only for response bodies that clients request while preserving one-generation race tolerance.
|
||||
- **An unrequested prior-generation map reads the current map file** — combo revisions track executable bundles, not debug artifacts. If HMR rebuilds a map before the retained prior URL receives its first map `GET`, that response uses the current authored map with the prior bundle offsets; requesting the map before the rebuild fixes that URL's response.
|
||||
|
||||
@@ -41,6 +41,11 @@ application combo 脚本只携带每个插件的 `client.js` 入口,并在启
|
||||
|
||||
已打开的 Web 页面通过 HMR 传输跟随 Host 的完整模块图。启用普通插件会添加其 Loader 条目;停用会移除条目,并在其异步 effect 完成清理后回收未使用的模块与样式。再次启用会加载一个带样式的实例。其他 Loader 贡献方的条目及活动条目仍需使用的共享模块会保留。「设置 → 插件 → 插件列表」显示当前页面的同步失败,并提供不改变 Host 启用状态的重试。
|
||||
|
||||
<a id="host-release-changes"></a>
|
||||
### Host release 变化
|
||||
|
||||
每个图都以 `release` 携带 Host 的 release 身份:本包的 `version`(各包发布时与 CLI 共用同一版本号),以及 client 构建固化进 Host 构建产物的源码 `commit`。两个字段都相等才算同一个 release。页面只应用其上次应用的 release 的图,初始为页面启动时的 release。页面重连到另一个 release 的 Host 后,会暂存该 release 的最新图、忽略它的 rebuilt 帧,通过 `ctx.modules.entries.releaseChange` 发布页面启动时的 release、上次应用的 release 和新 release,并让当前插件继续运行。`acceptRelease()` 把暂存的 release 设为当前 release 并应用其图;刷新页面则直接启动新 release。收到当前 release 的图会撤回待确认的变化。当前 release 只保存在页面内存中。
|
||||
|
||||
### 共享模块
|
||||
|
||||
外壳初始化一张冻结的模块表([`PLATFORM_MODULES`](../web/README.zh.md#the-shared-module-table));每个动态 bundle 都精确针对该基座解析其 external。`dsh.client.external` 只添加基座之外的精确请求;系统会将每个请求解析到其指定的动态包 row 或完全匹配的静态表键。纯类型 import 会被擦除,不产生请求。组合阶段会拒绝畸形请求、缺失提供方、自请求与同步请求环。
|
||||
@@ -81,7 +86,7 @@ bundle 路由随注入的 `webServer` 生命周期注册:服务就绪时注册
|
||||
|
||||
### 条目所有权
|
||||
|
||||
`ClientEntries` 记录启动时创建的条目,并在同一个 Loader 上串行执行完整图更新、重试和代码重载。本地代际阻止旧下载在目标条目或代码变化后挂载;目标相同的快照共用进行中的加载。新增模块使用单资源 URL,不会重新执行可能重复注册现有 factory 的启动 batch。Factory 在条目创建前就保留产物 revision;图更新会在导入消费者前丢弃未归属条目的陈旧 factory、其样式和失败的到达目标。清理会保留每个剩余 Loader 条目的已声明及已观察到的传递模块依赖。其可观察状态不导入运行时库,因为 modules bootstrap 在平台种子可用之前物化。
|
||||
`ClientEntries` 记录启动时创建的条目,并在同一个 Loader 上串行执行完整图更新、重试和代码重载。本地代际阻止旧下载在目标条目或代码变化后挂载;目标相同的快照共用进行中的加载。来自其他 release 的图不进入队列:重试继续调和上次应用的图,页面接受该 release 之前一律忽略 rebuilt 帧。新增模块使用单资源 URL,不会重新执行可能重复注册现有 factory 的启动 batch。Factory 在条目创建前就保留产物 revision;图更新会在导入消费者前丢弃未归属条目的陈旧 factory、其样式和失败的到达目标。清理会保留每个剩余 Loader 条目的已声明及已观察到的传递模块依赖。其可观察状态不导入运行时库,因为 modules bootstrap 在平台种子可用之前物化。
|
||||
|
||||
### 源码索引
|
||||
|
||||
@@ -129,6 +134,8 @@ bundle 路由随注入的 `webServer` 生命周期注册:服务就绪时注册
|
||||
|
||||
- **基于元数据的 revision**——revision 标识文件系统代际,而非内容相等性。仅元数据变化也可能重载插件;mtime、ctime 和大小都无法反映的变化无法区分。
|
||||
- **有意采用扁平模块图**——每个 bundle 是一个模块节点,其边只指向表中的叶节点;接口(`loadCache`/`edges`/`invalidate`)已经支持通用模块图,因此可以改变 externalization 粒度而不更改接口。
|
||||
- **Host release 检测依赖图事件流**——页面只能从图帧得知 Host 换了 release。重启中的 Host 若在注册路由之前对 `/plugins/events` 返回非 200 状态,浏览器会永久关闭该事件流,页面在刷新前既不更新插件,也检测不到 release 变化。
|
||||
- **源码启动时 Host 半侧没有构建步骤**——从 TypeScript 源码启动的 Host 读取 `pnpm run dev:web` 放入其环境的 commit;没有该值的源码启动只发布版本号。
|
||||
- **Bootstrap 与代码替换限制**——页面保留 modules bootstrap 和静态平台模块的身份。移除或替换 bootstrap 需要刷新页面;动态替换请求会报告页面本地错误,并保留其 fiber 与导出;替换包代码及其所有现有消费者不属于普通启停同步。
|
||||
- **惰性提供会保留已请求的 body**——Host 在内存中保留每个 bundle 与惰性响应计划;脚本或 map body 在首次 `GET` 后保留缓存,HMR 还会保留上一代启动响应。内存仅随客户端实际请求的响应 body 增长,同时保留一代竞态容忍。
|
||||
- **从未请求的上一代 map 会读取当前 map 文件**——combo revision 跟踪可执行 bundle,而不跟踪调试产物。若 HMR 在保留的旧 URL 首次收到 map `GET` 前重建 map,该响应会把当前 authored map 与旧 bundle offset 组合;在重建前请求 map 会固定该 URL 的响应。
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Entry, Loader } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import { parseBootManifest } from './manifest.ts'
|
||||
import type { BootManifest, ClientModuleLoader } from './manifest.ts'
|
||||
import { ReleaseChangeGate, type ClientReleaseChange } from './release-change.ts'
|
||||
import { removeOwnedStyles, tearDownEntryFiber } from './entry-lifecycle.ts'
|
||||
|
||||
/** Page-local failures do not change the Host's bundle enablement. */
|
||||
@@ -43,6 +44,8 @@ export class ClientEntries {
|
||||
// The modules bootstrap factory cannot request platform libraries before the shell supplies its seed.
|
||||
private snapshot: ClientEntryState = { syncing: false, failures: [] }
|
||||
private readonly listeners = new Set<() => void>()
|
||||
/** Holds graphs of another Host release until the page accepts that release. */
|
||||
private readonly release: ReleaseChangeGate
|
||||
private readonly managed = new Map<string, Entry>()
|
||||
private readonly revisions = new Map<string, string>()
|
||||
private loader: Loader | undefined
|
||||
@@ -58,6 +61,12 @@ export class ClientEntries {
|
||||
*/
|
||||
constructor(private readonly modules: ClientModuleLoader, private readonly index: ModuleIndex) {
|
||||
this.desired = modules.manifest
|
||||
this.release = new ReleaseChangeGate(modules.manifest.release)
|
||||
}
|
||||
|
||||
/** Pending Host release change; undefined while every received graph carries the release this page last applied. */
|
||||
get releaseChange(): ObservableSnapshot<ClientReleaseChange | undefined> {
|
||||
return this.release.change
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,15 +95,22 @@ export class ClientEntries {
|
||||
|
||||
/**
|
||||
* Validate and apply the latest full Host graph. Changed targets cancel obsolete mounts; identical targets share pending loads.
|
||||
* A graph of another release than the last applied one is held and announced through {@link releaseChange} instead.
|
||||
* @param graph - JSON-decoded graph received from the Host.
|
||||
* @returns after the queued reconciliation; per-package failures remain available in {@link state}.
|
||||
* @returns after the queued reconciliation, or at once for a held graph; per-package failures remain available in {@link state}.
|
||||
*/
|
||||
sync(graph: unknown): Promise<void> {
|
||||
const manifest = parseBootManifest(graph)
|
||||
if (entryTargets(manifest) !== entryTargets(this.desired)) this.generation++
|
||||
this.desired = manifest
|
||||
const generation = this.generation
|
||||
return this.enqueue(() => this.reconcile(generation))
|
||||
return this.release.admit(manifest) ? this.adopt(manifest) : Promise.resolve()
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept the pending Host release and apply its latest graph; later graphs of that release apply without asking.
|
||||
* @returns after the queued reconciliation; resolves at once when no release change is pending.
|
||||
*/
|
||||
acceptRelease(): Promise<void> {
|
||||
const held = this.release.accept()
|
||||
return held === undefined ? Promise.resolve() : this.adopt(held)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,11 +125,13 @@ export class ClientEntries {
|
||||
/**
|
||||
* Replace one entry's code in the same queue as graph updates; duplicate revisions are ignored.
|
||||
* Entries missing after a failed import are reconciled; bootstrap replacement fails before teardown.
|
||||
* While a release change is pending the frame is ignored: the Host's graph frame that follows every rebuild carries the revision.
|
||||
* @param id - Package id from a rebuilt frame.
|
||||
* @param rev - Opaque revision selecting the rebuilt artifact.
|
||||
* @returns after queued work; replacement errors reject, while per-package reconciliation errors remain in {@link state}.
|
||||
*/
|
||||
reload(id: string, rev: string): Promise<void> {
|
||||
if (this.release.holding) return Promise.resolve()
|
||||
this.desired = {
|
||||
...this.desired,
|
||||
modules: this.desired.modules.map(row => row.id === id ? { ...row, rev } : row),
|
||||
@@ -147,6 +165,15 @@ export class ClientEntries {
|
||||
}
|
||||
}
|
||||
|
||||
/** Reconcile a graph the release gate let through. */
|
||||
private adopt(manifest: BootManifest): Promise<void> {
|
||||
this.release.applied(manifest)
|
||||
if (entryTargets(manifest) !== entryTargets(this.desired)) this.generation++
|
||||
this.desired = manifest
|
||||
const generation = this.generation
|
||||
return this.enqueue(() => this.reconcile(generation))
|
||||
}
|
||||
|
||||
private enqueue(task: () => Promise<void>, subject = 'graph'): Promise<void> {
|
||||
const run = this.queue.then(task)
|
||||
// Each operation reports its own failure; later operations must still run.
|
||||
|
||||
@@ -20,13 +20,14 @@ import type {
|
||||
|
||||
export { ClientModuleSystem }
|
||||
export { tearDownEntryFiber } from './entry-lifecycle.ts'
|
||||
export type { ClientReleaseChange } from './release-change.ts'
|
||||
export type { ClientEntries, ClientEntryState } from './entries.ts'
|
||||
export { exactPackageSpecifier, parseBootManifest, parseDshClient, stripClientSuffix } from './manifest.ts'
|
||||
export type {
|
||||
BootManifest, BootModuleRow, BootPluginRow, ClientBootstrapModule, ClientBundleRegistration,
|
||||
ClientModuleCreateOptions, ClientModuleLoader, ClientModuleLoaderTarget, ClientModuleRecord,
|
||||
ClientModuleSystemOptions, DshWindow,
|
||||
WebBootEntry, WebBootGraph,
|
||||
WebBootRelease, WebBootEntry, WebBootGraph,
|
||||
} from './manifest.ts'
|
||||
|
||||
/**
|
||||
|
||||
@@ -82,10 +82,20 @@ export interface WebBootBatch {
|
||||
entries: string[]
|
||||
}
|
||||
|
||||
/** Host release identity; two releases are the same when both fields are equal. */
|
||||
export interface WebBootRelease {
|
||||
/** Release version label. */
|
||||
version: string
|
||||
/** Source commit fixed in when the Host was built; absent when the build recorded none. */
|
||||
commit?: string
|
||||
}
|
||||
|
||||
/** The composed client entry graph the host injects as `window.__DSH_BOOT__`. */
|
||||
export interface WebBootGraph {
|
||||
/** Consistency anchor over the current entry and batch descriptors. */
|
||||
rev: string
|
||||
/** Host release identity; absent from a Host that publishes none. */
|
||||
release?: WebBootRelease
|
||||
/**
|
||||
* Composed entries in module-graph order — a dynamic package row precedes
|
||||
* rows whose `external` requests that package. Cordis activation order is
|
||||
@@ -126,6 +136,8 @@ export interface BootPluginRow {
|
||||
export interface BootManifest {
|
||||
/** Consistency anchor over the whole graph. */
|
||||
rev: string
|
||||
/** Host release identity carried by the graph; absent when the Host publishes none. */
|
||||
release?: WebBootRelease
|
||||
/** Rows as the module table consumes them. */
|
||||
modules: BootModuleRow[]
|
||||
/** Rows as entry composition consumes them. */
|
||||
@@ -222,6 +234,7 @@ export function parseBootManifest(wire: unknown): BootManifest {
|
||||
if (typeof graph.rev !== 'string') {
|
||||
throw new Error('client-modules: boot manifest rev must be a string')
|
||||
}
|
||||
const release = parseRelease(graph.release)
|
||||
if (!Array.isArray(graph.entries)) {
|
||||
throw new Error('client-modules: boot manifest entries must be an array')
|
||||
}
|
||||
@@ -302,7 +315,18 @@ export function parseBootManifest(wire: unknown): BootManifest {
|
||||
}
|
||||
return { ...row, initialUrl }
|
||||
})
|
||||
return { rev: graph.rev, modules, plugins }
|
||||
return { rev: graph.rev, ...(release === undefined ? {} : { release }), modules, plugins }
|
||||
}
|
||||
|
||||
/** Validate the optional wire release identity and drop members outside {@link WebBootRelease}. */
|
||||
function parseRelease(value: unknown): WebBootRelease | undefined {
|
||||
if (value === undefined) return undefined
|
||||
const release = typeof value === 'object' && value !== null ? value as Record<string, unknown> : undefined
|
||||
const commit = release?.commit
|
||||
if (release === undefined || typeof release.version !== 'string' || (commit !== undefined && typeof commit !== 'string')) {
|
||||
throw new Error('client-modules: boot manifest release must carry a string version and an optional string commit')
|
||||
}
|
||||
return { version: release.version, ...(commit === undefined ? {} : { commit }) }
|
||||
}
|
||||
|
||||
/** Module resolver passed into a registered Client bundle factory. */
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/** Host release changes held back from a page whose code belongs to another release. */
|
||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import type { BootManifest, WebBootRelease } from './manifest.ts'
|
||||
|
||||
/** A Host graph whose release differs from the release this page last applied; its code waits for acceptance or a page reload. */
|
||||
export interface ClientReleaseChange {
|
||||
/** Release this page booted from; its shell and static modules stay at this release until a reload. */
|
||||
readonly page: WebBootRelease | undefined
|
||||
/** Release of the latest graph this page applied: its boot release or an accepted one. */
|
||||
readonly current: WebBootRelease | undefined
|
||||
/** Release of the latest Host graph. */
|
||||
readonly next: WebBootRelease | undefined
|
||||
}
|
||||
|
||||
/** Whether two releases are the same: both absent, or equal in every field. */
|
||||
function sameRelease(left: WebBootRelease | undefined, right: WebBootRelease | undefined): boolean {
|
||||
return left?.version === right?.version && left?.commit === right?.commit
|
||||
}
|
||||
|
||||
/** Release identity as diagnostic text. */
|
||||
function describeRelease(release: WebBootRelease | undefined): string {
|
||||
return release === undefined ? 'none' : JSON.stringify(release)
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the latest Host graph of another release than the one this page last applied. The owner applies
|
||||
* only graphs that {@link admit} passes or {@link accept} releases, and reports each one through
|
||||
* {@link applied}. The current release lives in page memory, so a reload starts from the new boot release.
|
||||
*/
|
||||
export class ReleaseChangeGate {
|
||||
/** Pending Host release change; undefined while every received graph carries the current release. */
|
||||
readonly change: ObservableSnapshot<ClientReleaseChange | undefined> = {
|
||||
getSnapshot: () => this.snapshot,
|
||||
subscribe: (listener) => {
|
||||
this.listeners.add(listener)
|
||||
return () => { this.listeners.delete(listener) }
|
||||
},
|
||||
}
|
||||
private snapshot: ClientReleaseChange | undefined
|
||||
private readonly listeners = new Set<() => void>()
|
||||
private current: WebBootRelease | undefined
|
||||
private held: BootManifest | undefined
|
||||
|
||||
/**
|
||||
* Start from the release the page booted from.
|
||||
* @param page - boot graph's release identity.
|
||||
*/
|
||||
constructor(private readonly page: WebBootRelease | undefined) {
|
||||
this.current = page
|
||||
}
|
||||
|
||||
/** Whether a graph of another release is held; its rebuilt frames carry nothing the next graph frame lacks. */
|
||||
get holding(): boolean {
|
||||
return this.held !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Pass a graph of the current release; hold any other one and announce each new release once.
|
||||
* @param manifest - parsed Host graph.
|
||||
* @returns true when the owner applies the graph now.
|
||||
*/
|
||||
admit(manifest: BootManifest): boolean {
|
||||
if (sameRelease(manifest.release, this.current)) return true
|
||||
this.held = manifest
|
||||
if (this.snapshot === undefined || !sameRelease(this.snapshot.next, manifest.release)) {
|
||||
console.warn(`client-modules: Host release changed from ${describeRelease(this.current)} to ${describeRelease(manifest.release)}; plugin updates pause until this page accepts it or reloads`)
|
||||
this.publish({ page: this.page, current: this.current, next: manifest.release })
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the held graph for the owner to apply.
|
||||
* @returns the held graph, or undefined when no release change is pending.
|
||||
*/
|
||||
accept(): BootManifest | undefined {
|
||||
return this.held
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a graph the owner applied as the current release, withdrawing any pending change.
|
||||
* @param manifest - graph about to be reconciled.
|
||||
*/
|
||||
applied(manifest: BootManifest): void {
|
||||
this.held = undefined
|
||||
this.current = manifest.release
|
||||
if (this.snapshot !== undefined) this.publish(undefined)
|
||||
}
|
||||
|
||||
private publish(snapshot: ClientReleaseChange | undefined): void {
|
||||
this.snapshot = snapshot
|
||||
for (const listener of [...this.listeners]) {
|
||||
try {
|
||||
listener()
|
||||
} catch (error) {
|
||||
// The page controller has no owning plugin Context for a scoped logger.
|
||||
console.error('client-modules: release change subscriber failed', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,11 +34,11 @@ import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { exactPackageSpecifier, parseDshClient, stripClientSuffix } from './client/manifest.ts'
|
||||
import type { WebBootBatch, WebBootBatchPhase, WebBootEntry, WebBootGraph } from './client/manifest.ts'
|
||||
import type { WebBootBatch, WebBootBatchPhase, WebBootRelease, WebBootEntry, WebBootGraph } from './client/manifest.ts'
|
||||
|
||||
export { stripClientSuffix } from './client/manifest.ts'
|
||||
export type {
|
||||
BootManifest, BootModuleRow, BootPluginRow, WebBootBatch, WebBootBatchPhase, WebBootEntry, WebBootGraph,
|
||||
BootManifest, BootModuleRow, BootPluginRow, WebBootBatch, WebBootBatchPhase, WebBootRelease, WebBootEntry, WebBootGraph,
|
||||
} from './client/manifest.ts'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
@@ -596,6 +596,11 @@ window.__ModuleLoader__={
|
||||
export class ClientModuleRegistry extends Service {
|
||||
static inject = ['loader']
|
||||
|
||||
/**
|
||||
* Host release identity published in every graph: this package's version, which releases share
|
||||
* with the CLI, and the source commit the client build fixes into the built Host artifact.
|
||||
*/
|
||||
private readonly release: WebBootRelease
|
||||
private readonly table = new Map<string, WebPluginRecord>()
|
||||
private readonly sources = new Map<string, ClientPackageSource>()
|
||||
// Resolution is entry-local: the same specifier can resolve differently in
|
||||
@@ -618,6 +623,10 @@ export class ClientModuleRegistry extends Service {
|
||||
*/
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'clientModules')
|
||||
const { version } = createRequire(import.meta.url)('../package.json') as { version: string }
|
||||
// The client build fixes this read into the built Host artifact.
|
||||
const commit = process.env.DSH_CLIENT_COMMIT_HASH
|
||||
this.release = commit === undefined ? { version } : { version, commit }
|
||||
// Subscribe before seeding so a fiber arriving mid-activation lands in the
|
||||
// same dirty set (Set idempotence makes the overlap harmless). An entry-less
|
||||
// fiber is a child plugin or a manual mount — never a loader row; O(1) drop.
|
||||
@@ -805,7 +814,12 @@ export class ClientModuleRegistry extends Service {
|
||||
this.batchResponses = batchResponses
|
||||
this.responses = responses
|
||||
const batches = artifacts.map(artifact => artifact.descriptor)
|
||||
return { rev: shortHash(JSON.stringify({ entries, batches })), entries, batches }
|
||||
return {
|
||||
rev: shortHash(JSON.stringify({ entries, batches })),
|
||||
release: this.release,
|
||||
entries,
|
||||
batches,
|
||||
}
|
||||
}
|
||||
|
||||
private notifyGraphChanged(): void {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { apply as provideModules, createClientModuleSystem } from '../src/client/index.ts'
|
||||
import type { ClientBundleRegistration, ClientModuleLoaderTarget, WebBootEntry, WebBootGraph } from '../src/client/index.ts'
|
||||
import type { ClientBundleRegistration, ClientModuleLoaderTarget, WebBootRelease, WebBootEntry, WebBootGraph } from '../src/client/index.ts'
|
||||
|
||||
const contexts: Context[] = []
|
||||
afterEach(async () => {
|
||||
@@ -515,3 +515,100 @@ it('cleans styles from a materialized factory superseded before its entry is cre
|
||||
expect(effects).toEqual({ mounted: 1, disposed: 0, hits: 0 })
|
||||
expect(document.querySelectorAll('style[data-plugin=a]')).toHaveLength(1)
|
||||
})
|
||||
|
||||
describe('Host release changes', () => {
|
||||
const built = (release: WebBootRelease | undefined, ...entries: WebBootEntry[]): WebBootGraph => ({
|
||||
...graph(...entries), ...(release === undefined ? {} : { release }),
|
||||
})
|
||||
const b1: WebBootRelease = { version: '1.0.0', commit: 'aaaaaaa' }
|
||||
const b2: WebBootRelease = { version: '1.0.0', commit: 'bbbbbbb' }
|
||||
const b3: WebBootRelease = { version: '2.0.0' }
|
||||
|
||||
it('pauses code replacement for another release until the page accepts it', async () => {
|
||||
const effects = { mounted: 0, disposed: 0, hits: 0 }
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const b = await bench(built(b1, row('pet')), { pet: visible('pet', effects) })
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = b.modules.entries.releaseChange.subscribe(listener)
|
||||
expect(b.modules.entries.releaseChange.getSnapshot()).toBeUndefined()
|
||||
// A different commit of the same version is another release.
|
||||
await b.modules.entries.sync(built(b2, row('pet', 'r1')))
|
||||
// Rebuilt frames of the pending release are ignored; its next graph frame carries the revision.
|
||||
await b.modules.entries.reload('pet', 'r2')
|
||||
expect(effects).toEqual({ mounted: 1, disposed: 0, hits: 0 })
|
||||
expect(b.modules.entries.releaseChange.getSnapshot()).toEqual({ page: b1, current: b1, next: b2 })
|
||||
expect(listener).toHaveBeenCalledOnce()
|
||||
expect(warn).toHaveBeenCalledWith(`client-modules: Host release changed from ${JSON.stringify(b1)} to ${JSON.stringify(b2)}; plugin updates pause until this page accepts it or reloads`)
|
||||
// Repeated frames for the pending release neither replace code nor announce again.
|
||||
await b.modules.entries.sync(built({ ...b2 }, row('pet', 'r2')))
|
||||
expect(listener).toHaveBeenCalledOnce()
|
||||
await b.modules.entries.acceptRelease()
|
||||
expect(b.modules.entries.releaseChange.getSnapshot()).toBeUndefined()
|
||||
expect(listener).toHaveBeenCalledTimes(2)
|
||||
expect(effects).toEqual({ mounted: 2, disposed: 1, hits: 0 })
|
||||
expect(b.fetched.at(-1)).toBe(row('pet', 'r2').url)
|
||||
// The accepted release replaces code without asking again.
|
||||
await b.modules.entries.sync(built(b2, row('pet', 'r2')))
|
||||
await b.modules.entries.reload('pet', 'r3')
|
||||
expect(effects).toEqual({ mounted: 3, disposed: 2, hits: 0 })
|
||||
expect(listener).toHaveBeenCalledTimes(2)
|
||||
// An unsubscribed listener hears no later change; the page keeps its boot release.
|
||||
unsubscribe()
|
||||
await b.modules.entries.sync(built(b3, row('pet', 'r3')))
|
||||
expect(b.modules.entries.releaseChange.getSnapshot()).toEqual({ page: b1, current: b2, next: b3 })
|
||||
expect(listener).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('asks for every release other than the last applied one, including the boot release', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const b = await bench(built(b1, row('pet')), { pet: () => ({ apply() {} }) })
|
||||
await b.modules.entries.sync(built(b2, row('pet')))
|
||||
await b.modules.entries.acceptRelease()
|
||||
await b.modules.entries.sync(built(b1, row('pet')))
|
||||
expect(b.modules.entries.releaseChange.getSnapshot()).toEqual({ page: b1, current: b2, next: b1 })
|
||||
await b.modules.entries.sync(built(b3, row('pet')))
|
||||
expect(b.modules.entries.releaseChange.getSnapshot()).toEqual({ page: b1, current: b2, next: b3 })
|
||||
})
|
||||
|
||||
it('withdraws a pending prompt when the Host returns to the applied release', async () => {
|
||||
const effects = { mounted: 0, disposed: 0, hits: 0 }
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const b = await bench(built(b1, row('pet')), { pet: visible('pet', effects) })
|
||||
const listener = vi.fn()
|
||||
b.modules.entries.releaseChange.subscribe(listener)
|
||||
await b.modules.entries.sync(built(b2, row('pet', 'r1')))
|
||||
await b.modules.entries.sync(built(b1, row('pet', 'r1')))
|
||||
expect(b.modules.entries.releaseChange.getSnapshot()).toBeUndefined()
|
||||
expect(listener).toHaveBeenCalledTimes(2)
|
||||
expect(effects).toEqual({ mounted: 2, disposed: 1, hits: 0 })
|
||||
// Accepting without a pending change leaves the page unchanged.
|
||||
await b.modules.entries.acceptRelease()
|
||||
expect(effects.mounted).toBe(2)
|
||||
})
|
||||
|
||||
it('treats an absent release as one identity', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const b = await bench(graph(), {})
|
||||
await b.modules.entries.sync(graph())
|
||||
expect(b.modules.entries.releaseChange.getSnapshot()).toBeUndefined()
|
||||
await b.modules.entries.sync(built(b3))
|
||||
expect(b.modules.entries.releaseChange.getSnapshot()).toEqual({ page: undefined, current: undefined, next: b3 })
|
||||
expect(warn).toHaveBeenCalledWith(`client-modules: Host release changed from none to ${JSON.stringify(b3)}; plugin updates pause until this page accepts it or reloads`)
|
||||
await b.modules.entries.acceptRelease()
|
||||
await b.modules.entries.sync(graph())
|
||||
expect(b.modules.entries.releaseChange.getSnapshot()).toEqual({ page: undefined, current: b3, next: undefined })
|
||||
})
|
||||
|
||||
it('notifies every subscriber when one of them throws', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const b = await bench(built(b1), {})
|
||||
const failure = new Error('subscriber')
|
||||
const listener = vi.fn()
|
||||
b.modules.entries.releaseChange.subscribe(() => { throw failure })
|
||||
b.modules.entries.releaseChange.subscribe(listener)
|
||||
await b.modules.entries.sync(built(b2))
|
||||
expect(listener).toHaveBeenCalledOnce()
|
||||
expect(error).toHaveBeenCalledWith('client-modules: release change subscriber failed', failure)
|
||||
})
|
||||
})
|
||||
@@ -694,6 +694,19 @@ describe('boot manifest wire', () => {
|
||||
.toThrow('client-modules: boot manifest batches must be an array')
|
||||
})
|
||||
|
||||
it('carries an optional Host release and rejects a malformed one', () => {
|
||||
const wire = { rev: 'graph', entries: [], batches: [] }
|
||||
expect(parseBootManifest(wire)).not.toHaveProperty('release')
|
||||
expect(parseBootManifest({ ...wire, release: { version: '1.0.0' } }).release).toEqual({ version: '1.0.0' })
|
||||
// Members outside the identity are dropped so equality compares only version and commit.
|
||||
expect(parseBootManifest({ ...wire, release: { version: '1.0.0', commit: 'abc1234', label: 'x' } }).release)
|
||||
.toEqual({ version: '1.0.0', commit: 'abc1234' })
|
||||
for (const release of ['1.0.0', null, {}, { version: 1 }, { version: '1.0.0', commit: 7 }]) {
|
||||
expect(() => parseBootManifest({ ...wire, release }))
|
||||
.toThrow('client-modules: boot manifest release must carry a string version and an optional string commit')
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects malformed batch phases', () => {
|
||||
const entry = { id: 'a', url: '/a.js', rev: '1' }
|
||||
expect(() => parseBootManifest({ rev: 'graph', entries: [entry], batches: [null] }))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Node-half composition diagnostics for package metadata and built client bundles. */
|
||||
|
||||
import { Hash } from 'node:crypto'
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import { SourceMap } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -79,6 +79,24 @@ it.each([false, true])('tracks the Web carrier lifetime when server-first is %s'
|
||||
expect(routes.size).toBe(0)
|
||||
})
|
||||
|
||||
it('publishes this package version and the build commit as the Host release in every composed graph', async () => {
|
||||
const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }
|
||||
const mount = async (commit: string | undefined) => {
|
||||
vi.stubEnv('DSH_CLIENT_COMMIT_HASH', commit)
|
||||
const ctx = new Context()
|
||||
contexts.push({ ctx })
|
||||
ctx.provide('loader', { entries: () => [] })
|
||||
await ctx.plugin(ClientModuleRegistry)
|
||||
vi.unstubAllEnvs()
|
||||
return ctx.get('clientModules')!.graph()
|
||||
}
|
||||
const graph = await mount('abc1234')
|
||||
expect(graph.release).toEqual({ version, commit: 'abc1234' })
|
||||
expect(bootInjections(graph).at(-1)).toEqual({ kind: 'global', name: '__DSH_BOOT__', value: graph })
|
||||
expect(modulesClient.parseBootManifest(graph).release).toEqual({ version, commit: 'abc1234' })
|
||||
expect((await mount(undefined)).release).toEqual({ version })
|
||||
})
|
||||
|
||||
/** Create a resolvable package whose client export points at the returned path. */
|
||||
function writePackage(
|
||||
packageName: string,
|
||||
|
||||
@@ -19,7 +19,7 @@ import { Rolldown, type TsdownPlugin, type UserConfig } from 'tsdown'
|
||||
import { transform } from 'lightningcss'
|
||||
import { optionalStringArray } from './modules/src/client/manifest.ts'
|
||||
import { PLATFORM_MODULES, PRELOADED_CLIENT_EXTERNALS } from './web/src/platform.ts'
|
||||
import { clientBuildEnvironmentDefines } from '../../scripts/client-build-environment.ts'
|
||||
import { clientBuildEnvironmentDefines, clientBuildValueDefines } from '../../scripts/client-build-environment.ts'
|
||||
import { isExperimentalPackageName } from '../../scripts/experimental-package-policy.ts'
|
||||
import { BundleInputIsolation, physicalBundleInput } from '../../scripts/bundle-input-isolation.ts'
|
||||
|
||||
@@ -243,6 +243,8 @@ function clientLibraryConfig(
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
// Public client build values are fixed into the Node half as they are into the browser half.
|
||||
define: clientBuildValueDefines(process.env),
|
||||
deps: {
|
||||
// The Node half runs from a real install: a production dependency is on
|
||||
// disk there and stays an import, everything else inlines. Stating both
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 2843d46e95311bce
|
||||
zh: ec11bdeb5d3ce818
|
||||
/deepseek-ai-dsh-client-ui-settings-general/use-this-package:
|
||||
en: 8b75b6fc5a0951e3
|
||||
zh: 90c54f660988588b
|
||||
en: 4e5046c43bfd1c07
|
||||
zh: 088df88202c6d6ae
|
||||
/deepseek-ai-dsh-client-ui-settings-general/use-this-package/the-general-section:
|
||||
en: 8b8a7c6287bb303b
|
||||
zh: 19535112531aedd5
|
||||
@@ -35,6 +35,9 @@
|
||||
/deepseek-ai-dsh-client-ui-settings-general/understand-the-implementation/connection-recovery:
|
||||
en: e37fb7df883ea356
|
||||
zh: c972524e56c6d6c2
|
||||
/deepseek-ai-dsh-client-ui-settings-general/understand-the-implementation/host-release-changes:
|
||||
en: 66c7903c64be9f84
|
||||
zh: 7fd0dcda5f4f083f
|
||||
/deepseek-ai-dsh-client-ui-settings-general/understand-the-implementation/document-availability:
|
||||
en: ff1aec9eb1a87a79
|
||||
zh: eb91ab8bef59742d
|
||||
|
||||
@@ -31,6 +31,8 @@ Users reach the shell through the sidebar's bottom Settings control; feature plu
|
||||
|
||||
Settings uses the [frame’s shared overlay inset](../ui-layout/README.md#window-chrome-seat) and keeps at least 24px of viewport margin. Its mask leaves the Windows caption unpainted; the full-viewport layer still blocks background clicks.
|
||||
|
||||
When the page reconnects to a Host running another release, a **Service version changed** dialog draws two lines: **Service** from the last applied release (gray) to the new release, joined by a thin solid green arrow because the switch already happened, and **Page** from the release this page booted from to the new release (gray), joined by an equally thin dashed gray arrow because only a reload completes it. A line shows versions when they differ and commits otherwise. **Reload to load the new version** loads the new release; **Not now**, the close control, a mask click, and Escape keep the page and apply the new release's plugin updates, after which reconnecting to that release no longer asks. Plugin updates stay paused until one of these choices.
|
||||
|
||||
When the section navigation exceeds the panel's available height, the list scrolls independently of the settings content and keeps the Settings title fixed.
|
||||
|
||||
In Desktop, the account-row update control shows availability, progress, verification, readiness, and persistent retry feedback. It shares the connection indicator’s 28px height, 8px corners, 14px icon slot, 4px icon gap, and 12px medium text with an 18px line height; update-state border, fill, and text colors remain independently defined. Retry text and its dot use the same brand blue as other update labels; numeric download progress has no ellipsis. The preload carries semantic phase, version, progress, and classified failures; the component resolves every visible and accessible string from the active `settings` locale, including after an in-application language change. Selecting an available update starts downloading; installation requires a separate shell-owned confirmation. A collapsed sidebar shows the same status as a brand-blue dot on its top expand button, including failures. Connection feedback takes priority except during shell-reported installation, when the expected backend disconnect must not hide update status. Failure restores connection feedback. Both controls share one carrier subscription; browser code cannot choose packages or authorize installation. [Desktop updates](../../../apps/desktop/README.md) owns the release workflow. Checking, verifying, and preparing-to-restart tooltips show only the supplied version; without a version, they retain the state label. Desktop update tooltips preserve the complete version and prerelease suffix without adding v or V; their error summaries match the shell update dialogs.
|
||||
@@ -73,6 +75,10 @@ The navigation is a projection of the `settings.section` ledger; nav labels may
|
||||
|
||||
The shell is an explicit recovery consumer, so it injects Connection directly rather than adding lifecycle controls to `ctx.remote`. Its private hooks compartment binds `ctx.connection.state`, while the component receives only the selected state and an injected callback for `ctx.connection.reconnect()`. `ConnectionIndicator` owns the inline presentation and receives all visible and accessible copy from the `settings` locale namespace; the shell owns the 800ms minimum-visible hold for the connecting state and the two-second recovered-state timer, which starts when the recovered pill becomes visible after the hold.
|
||||
|
||||
### Host release changes
|
||||
|
||||
The `shell.overlay` entry `host-release-change` binds `ctx.modules.entries.releaseChange` in its hooks compartment and receives two callbacks: one calls `ctx.modules.entries.acceptRelease()`, the other reloads the page. Release identities are Host data and render verbatim; [Client Modules](../modules/README.md#host-release-changes) owns which release counts as current.
|
||||
|
||||
### Document availability
|
||||
|
||||
On a loopback page, the Client loads the provider's `hasDocument` capability through `settings/describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action calls the pathless, browser-authenticated `settings/openSettingsDocument` Remote; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows; Windows association after `wslpath -w` translation on WSL). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Non-loopback pages withhold this native action through the Client policy.
|
||||
|
||||
@@ -31,6 +31,8 @@ kind: "package-reference"
|
||||
|
||||
设置面板使用[框架共享的浮层起点](../ui-layout/README.zh.md#window-chrome-seat),视口边距至少为 24px。其遮罩不绘制 Windows 顶栏区域,覆盖整个视口的模态层仍阻挡背景点击。
|
||||
|
||||
页面重连到运行另一个 release 的 Host 时,会弹出**服务版本变化**对话框,画出两条线:**服务**从上次应用的 release(灰色)指向新 release,用细的绿色实线箭头连接,表示切换已经完成;**页面**从页面启动时的 release 指向新 release(灰色),用同样细的灰色虚线箭头连接,表示要刷新后才完成。版本号不同时显示版本号,否则显示 commit。**刷新以加载新版本**加载新 release;**暂不刷新**、关闭按钮、点击遮罩和 Escape 都保留当前页面并应用新 release 的插件更新,之后重连到该 release 不再提示。做出选择之前,插件更新保持暂停。
|
||||
|
||||
当分区导航超出面板可用高度时,列表独立于设置内容滚动,并保持「设置」标题固定。
|
||||
|
||||
在 Desktop 中,账户行更新控件显示可用版本、进度、验证、就绪和持久重试反馈。它与连接指示器共用 28px 高度、8px 圆角、14px 图标占位、4px 图文间距,以及 12px 中等字重文字和 18px 行高;更新状态的边框、填充和文字颜色独立定义。重试文字及其圆点与其他更新文案使用相同的品牌蓝色;数字下载进度不带省略号。预加载仅传递语义化阶段、版本、进度和失败类别;组件从当前 `settings` locale 解析全部可见与无障碍文案,并在应用内语言变化后同步更新。选择可用更新即开始下载;安装需要独立的壳拥有的确认。侧栏收起时,其顶部展开按钮以品牌蓝色圆点显示相同状态,包括失败状态。连接反馈通常优先展示,但壳报告正在安装时,预期的后端断开不能遮盖更新状态;失败后恢复连接反馈。两个控件共用一次载体订阅;浏览器代码不能选择安装包或授权安装。[Desktop 更新](../../../apps/desktop/README.zh.md)负责发布流程。检查中、校验中和准备重启状态的悬浮提示只显示已提供的版本号;没有版本号时保留状态文案。桌面更新提示完整保留版本号及预发布后缀,不额外添加 v 或 V;错误提示与桌面升级弹窗保持一致。
|
||||
@@ -73,6 +75,10 @@ Web 与桌面端的通用设置底部显示当前发布版本,使用构建注
|
||||
|
||||
外壳是明确的恢复功能消费方,因此直接注入 Connection,而不把生命周期控制放进 `ctx.remote`。它的私有 hooks compartment 绑定 `ctx.connection.state`,组件只接收选出的状态与调用 `ctx.connection.reconnect()` 的注入回调。`ConnectionIndicator` 拥有内联展示并从 `settings` locale namespace 接收全部可见与无障碍文案;连接中状态的 800ms 最短可见驻留与 2 秒恢复确认计时器归外壳所有;恢复计时从驻留结束、恢复药丸实际可见时开始。
|
||||
|
||||
### Host release 变化
|
||||
|
||||
`shell.overlay` 条目 `host-release-change` 在其 hooks compartment 中绑定 `ctx.modules.entries.releaseChange`,并接收两个回调:一个调用 `ctx.modules.entries.acceptRelease()`,另一个刷新页面。release 身份是 Host 数据,按原样显示;哪个 release 算当前由 [Client Modules](../modules/README.zh.md#host-release-changes) 决定。
|
||||
|
||||
### 文档可用性
|
||||
|
||||
在 loopback 页面上,Client 通过 `settings/describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染**打开配置文件**操作。该操作调用无路径参数且经浏览器认证的 `settings/openSettingsDocument` Remote;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联;WSL 上经 `wslpath -w` 转换后使用 Windows 文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。非 loopback 页面通过 Client 策略拦下该原生操作。
|
||||
|
||||
@@ -37,7 +37,9 @@
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-api-remotes",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar",
|
||||
"@deepseek-ai/dsh-client-shortcuts"
|
||||
"@deepseek-ai/dsh-client-shortcuts",
|
||||
"@deepseek-ai/dsh-client-modules",
|
||||
"@deepseek-ai/dsh-client-ui-layout"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
@@ -58,8 +60,10 @@
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-modules": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-store": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-renderer": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-session": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
.dialog {
|
||||
width: min(480px, 100%);
|
||||
}
|
||||
|
||||
.transitions {
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(0, max-content) 56px minmax(0, max-content);
|
||||
justify-content: start;
|
||||
align-items: center;
|
||||
gap: 8px 12px;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.release {
|
||||
font-family: var(--ds-font-family-code);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.solid {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.faded {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* Both connectors share one 1px stroke; only style and color differ. */
|
||||
.line {
|
||||
position: relative;
|
||||
height: 0;
|
||||
border-top-width: 1px;
|
||||
}
|
||||
|
||||
/* Arrowhead at the end of the connector, centered on its stroke. */
|
||||
.line::after {
|
||||
position: absolute;
|
||||
top: -3.5px;
|
||||
right: -1px;
|
||||
border-top: 3px solid transparent;
|
||||
border-bottom: 3px solid transparent;
|
||||
border-left: 5px solid currentColor;
|
||||
content: '';
|
||||
}
|
||||
|
||||
.done {
|
||||
border-top-style: solid;
|
||||
border-top-color: var(--dsw-alias-state-success-primary);
|
||||
color: var(--dsw-alias-state-success-primary);
|
||||
}
|
||||
|
||||
.pending {
|
||||
border-top-style: dashed;
|
||||
border-top-color: var(--dsw-alias-label-tertiary);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/** Confirmation shown when this page reconnects to a Host running another release. */
|
||||
import clsx from 'clsx'
|
||||
import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ClientReleaseChange, WebBootRelease } from '@deepseek-ai/dsh-client-modules/client'
|
||||
import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// Type-only: pulls ui-layout's SlotMap merge (the 'shell.overlay' entry).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import css from './HostReleasePrompt.module.css'
|
||||
|
||||
/** Registrant-private face: the page's pending release change and its two decisions. */
|
||||
export type HostReleasePromptInjected = {
|
||||
/** Keep this page and apply the new release's plugin updates; later graphs of that release apply without asking. */
|
||||
acceptRelease: () => void
|
||||
/** Reload the page from the Host. */
|
||||
reloadPage: () => void
|
||||
hooks: {
|
||||
/** Pending Host release change owned by the page's module system. */
|
||||
releaseChange: HostObservable<ClientReleaseChange | undefined>
|
||||
}
|
||||
}
|
||||
|
||||
/** Overlay props: the root overlay owner share, the `settings` copy, and the injected face. */
|
||||
export type HostReleasePromptProps =
|
||||
PropsRuntime<'shell.overlay'> & PropsLocale<'settings'> & InjectFace<HostReleasePromptInjected>
|
||||
|
||||
/**
|
||||
* Name both ends of one release transition: their versions when those differ, otherwise their commits.
|
||||
* @param from - release the transition starts at.
|
||||
* @param to - release the transition ends at.
|
||||
* @param unknown - text for a release or field the Host did not publish.
|
||||
* @returns the start and end text.
|
||||
*/
|
||||
function transitionText(from: WebBootRelease | undefined, to: WebBootRelease | undefined, unknown: string): [string, string] {
|
||||
const field = from?.version !== to?.version ? 'version' : 'commit'
|
||||
return [from?.[field] ?? unknown, to?.[field] ?? unknown]
|
||||
}
|
||||
|
||||
/**
|
||||
* One labelled transition line. A completed transition fades its start; a pending one fades its end.
|
||||
* @param props - row label, both ends, and completion.
|
||||
* @returns the row's cells for the shared transition grid.
|
||||
*/
|
||||
function Transition({ label, from, to, done }: { label: string; from: string; to: string; done: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<span className={css.label}>{label}</span>
|
||||
<span className={clsx(css.release, done ? css.faded : css.solid)}>{from}</span>
|
||||
<span aria-hidden="true" className={clsx(css.line, done ? css.done : css.pending)} />
|
||||
<span className={clsx(css.release, done ? css.solid : css.faded)}>{to}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask whether to reload after the Host changed releases; closing the dialog keeps the page.
|
||||
* The service line shows the completed switch from the last applied release; the page line shows the
|
||||
* pending switch from the release this page booted from.
|
||||
* @param props - the pending change, both decisions, and localized copy.
|
||||
* @returns the dialog while a change is pending; otherwise nothing.
|
||||
*/
|
||||
export function HostReleasePrompt({ useReleaseChange, acceptRelease, reloadPage, t }: HostReleasePromptProps) {
|
||||
const change = useReleaseChange(value => value)
|
||||
if (change === undefined) return null
|
||||
const unknown = t('hostRelease.unknown')
|
||||
const [serviceFrom, serviceTo] = transitionText(change.current, change.next, unknown)
|
||||
const [pageFrom, pageTo] = transitionText(change.page, change.next, unknown)
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
title={t('hostRelease.title')}
|
||||
description={t('hostRelease.description')}
|
||||
closeLabel={t('hostRelease.later')}
|
||||
onClose={acceptRelease}
|
||||
className={clsx(css.dialog)}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" onClick={acceptRelease}>{t('hostRelease.later')}</Button>
|
||||
<Button variant="primary" data-modal-autofocus onClick={reloadPage}>{t('hostRelease.reload')}</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className={css.transitions}>
|
||||
<Transition label={t('hostRelease.service')} from={serviceFrom} to={serviceTo} done />
|
||||
<Transition label={t('hostRelease.page')} from={pageFrom} to={pageTo} done={false} />
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
* `sidebar.settings` occupant — panel chrome, section navigation, and the
|
||||
* onboarding stage — and registers everything on the Settings pages that
|
||||
* belongs to no single feature: the trigger/header chrome content,
|
||||
* local-document action, General section, and `settings` dictionaries.
|
||||
* local-document action, General section, `settings` dictionaries, and the
|
||||
* Host release change confirmation.
|
||||
* Feature-owned rows and sections stay with their features.
|
||||
* Export discipline: packages/client/AGENTS.md.
|
||||
*/
|
||||
@@ -11,6 +12,8 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
||||
// Type-only: pulls the ctx.remote merge and its fixed Host facts.
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Type-only: pulls the ctx.modules merge.
|
||||
import type {} from '@deepseek-ai/dsh-client-modules/client'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { closeTopModal } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
// Type-only: the settings slot declarations plus the ctx.configForms Context
|
||||
@@ -37,6 +40,7 @@ import { DeveloperToolsRow, type DeveloperToolsRowInjected } from './DeveloperTo
|
||||
import { SettingsDocumentAction } from './SettingsDocumentAction.tsx'
|
||||
import type { SettingsDocumentActionInjected } from './SettingsDocumentAction.tsx'
|
||||
import { SettingsDocumentStore } from './settings-document-store.ts'
|
||||
import { HostReleasePrompt, type HostReleasePromptInjected } from './HostReleasePrompt.tsx'
|
||||
import { en, zh, type SettingsKey } from './locales.ts'
|
||||
|
||||
export type {
|
||||
@@ -65,7 +69,7 @@ const NS = 'settings'
|
||||
* ui-settings' apply, whose activation order relative to this one is NOT
|
||||
* constrained; registrations depend on their slots through `slots.inject()`.
|
||||
*/
|
||||
export const inject = ['slots', 'locale', 'connection', 'remote', 'remote.settings', 'configForms', 'shortcuts']
|
||||
export const inject = ['slots', 'locale', 'connection', 'remote', 'remote.settings', 'configForms', 'shortcuts', 'modules']
|
||||
|
||||
/**
|
||||
* Register the `settings` dictionaries, the chrome content, and the General
|
||||
@@ -236,4 +240,13 @@ export function apply(ctx: ClientContext): void {
|
||||
locale: NS,
|
||||
children: { 'settings.general.item': { kind: 'list', scope: 'root' } },
|
||||
}, GeneralSection))
|
||||
// The page's module system holds another Host release's plugin updates until this dialog's decision.
|
||||
const releasePromptInjected = (): HostReleasePromptInjected => ({
|
||||
acceptRelease: () => { void ctx.modules.entries.acceptRelease().catch((error: unknown) => { ctx.logger.error(error) }) },
|
||||
reloadPage: () => { globalThis.location.reload() },
|
||||
hooks: { releaseChange: ctx.modules.entries.releaseChange },
|
||||
})
|
||||
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
|
||||
name: 'shell.overlay', id: 'host-release-change', locale: NS, inject: releasePromptInjected,
|
||||
}, HostReleasePrompt))
|
||||
}
|
||||
@@ -36,6 +36,13 @@ export const zh = {
|
||||
'connection.connected': '连接成功',
|
||||
'connection.reconnect': '连接异常,点击立即重连',
|
||||
'connection.restart': '连接中断,正在重试,点击立即重连',
|
||||
'hostRelease.title': '服务版本变化',
|
||||
'hostRelease.description': '连接重新建立后版本信息变化如下',
|
||||
'hostRelease.service': '服务',
|
||||
'hostRelease.page': '页面',
|
||||
'hostRelease.unknown': '未标注',
|
||||
'hostRelease.reload': '刷新以加载新版本',
|
||||
'hostRelease.later': '暂不刷新',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** The settings namespace key union. */
|
||||
@@ -77,4 +84,11 @@ export const en = {
|
||||
'connection.connected': 'Connected',
|
||||
'connection.reconnect': 'Disconnected, reconnect now',
|
||||
'connection.restart': 'Reconnecting, reconnect now',
|
||||
'hostRelease.title': 'Service version changed',
|
||||
'hostRelease.description': 'After the connection was re-established, versions changed as follows.',
|
||||
'hostRelease.service': 'Service',
|
||||
'hostRelease.page': 'Page',
|
||||
'hostRelease.unknown': 'Not labeled',
|
||||
'hostRelease.reload': 'Reload to load the new version',
|
||||
'hostRelease.later': 'Not now',
|
||||
} satisfies Record<SettingsKey, string>
|
||||
@@ -10,10 +10,12 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock'
|
||||
import type { SettingsNamespaceView } from '@deepseek-ai/dsh-settings/types'
|
||||
import { createClientTest, type TestClient, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
|
||||
import { graphFromRoster } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/roster.ts'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { LOCALE_SETTINGS_NAMESPACE, LocaleSettingsSchema } from '@deepseek-ai/dsh-client-locale/src/locale-settings.ts'
|
||||
import { inject } from '../src/client/index.ts'
|
||||
import type { DeveloperToolsRowInjected } from '../src/client/DeveloperToolsRow.tsx'
|
||||
import { HostReleasePrompt, type HostReleasePromptInjected } from '../src/client/HostReleasePrompt.tsx'
|
||||
import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx'
|
||||
import { GeneralSection } from '../src/client/GeneralSection.tsx'
|
||||
import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx'
|
||||
@@ -89,7 +91,7 @@ function setPageUrl(url: string): void {
|
||||
|
||||
describe('ui-settings-general apply', () => {
|
||||
it('declares the services it uses', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'configForms', 'shortcuts'])
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'configForms', 'shortcuts', 'modules'])
|
||||
})
|
||||
|
||||
it('fills the five seats of the shell it declares, with the locale-following General label', async ({ mock, start }) => {
|
||||
@@ -278,4 +280,34 @@ describe('ui-settings-general apply', () => {
|
||||
expect(c.ctx.slots.entries('settings.general.item').filter(row => row.locale === NS)).toEqual([])
|
||||
expect(c.ctx.slots.spec('settings.general.item')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('asks about an unaccepted Host release in the shell overlay and resumes plugin updates once kept', async ({ mock, start }) => {
|
||||
const { c } = await client(mock, start)
|
||||
const overlay = () => c.ctx.slots.entries('shell.overlay').find(row => row.options.id === 'host-release-change')
|
||||
expect(overlay()?.component).toBe(HostReleasePrompt)
|
||||
expect(overlay()?.locale).toBe(NS)
|
||||
const injected = (overlay()!.inject as unknown as () => HostReleasePromptInjected)()
|
||||
expect(injected.hooks.releaseChange).toBe(c.ctx.modules.entries.releaseChange)
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const release = { version: '2.0.0' }
|
||||
await c.ctx.modules.entries.sync({ ...graphFromRoster(webApp.rows), release })
|
||||
expect(injected.hooks.releaseChange.getSnapshot()).toEqual({ page: undefined, current: undefined, next: release })
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
injected.acceptRelease()
|
||||
await vi.waitFor(() => { expect(injected.hooks.releaseChange.getSnapshot()).toBeUndefined() })
|
||||
// A rejected acceptance is reported rather than left unhandled.
|
||||
const failed = new Error('acceptance failed')
|
||||
vi.spyOn(c.ctx.modules.entries, 'acceptRelease').mockRejectedValueOnce(failed)
|
||||
const logged = vi.spyOn(c.ctx.logger, 'error').mockImplementation(() => {})
|
||||
injected.acceptRelease()
|
||||
await vi.waitFor(() => { expect(logged).toHaveBeenCalledWith(failed) })
|
||||
const reload = vi.fn()
|
||||
vi.stubGlobal('location', { reload })
|
||||
onTestFinished(() => { vi.unstubAllGlobals() })
|
||||
injected.reloadPage()
|
||||
expect(reload).toHaveBeenCalledOnce()
|
||||
await c.unload(SELF)
|
||||
await c.flush()
|
||||
expect(overlay()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -25,6 +25,7 @@ function derivedDocumentStore(remote: object) {
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
import { CurrentVersionRow } from '../src/client/CurrentVersionRow.tsx'
|
||||
import { DesktopUpdateBadge } from '../src/client/DesktopUpdateIndicator.tsx'
|
||||
import { HostReleasePrompt, type HostReleasePromptProps } from '../src/client/HostReleasePrompt.tsx'
|
||||
import type { DesktopUpdateView } from '../src/types.ts'
|
||||
|
||||
afterEach(() => { cleanup(); vi.unstubAllEnvs() })
|
||||
@@ -247,3 +248,63 @@ describe('current version', () => {
|
||||
expect(view.container.textContent).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Host release change confirmation', () => {
|
||||
type Build = { version: string; commit?: string }
|
||||
type Change = { page: Build | undefined; current: Build | undefined; next: Build | undefined } | undefined
|
||||
function prompt(change: Change, dictionary: Record<string, string> = en) {
|
||||
const acceptRelease = vi.fn()
|
||||
const reloadPage = vi.fn()
|
||||
const props: HostReleasePromptProps = {
|
||||
...kit,
|
||||
t: key => dictionary[key] ?? key,
|
||||
useReleaseChange: select => select(change),
|
||||
acceptRelease,
|
||||
reloadPage,
|
||||
}
|
||||
return { acceptRelease, reloadPage, view: render(<HostReleasePrompt {...props} />) }
|
||||
}
|
||||
|
||||
it('stays hidden while no build change is pending', () => {
|
||||
prompt(undefined)
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the service and page transitions and either reloads or keeps the page', () => {
|
||||
const { acceptRelease, reloadPage } = prompt({
|
||||
page: { version: '0.2.1' }, current: { version: '0.2.2' }, next: { version: '0.2.3', commit: 'abc1234' },
|
||||
})
|
||||
const dialog = screen.getByRole('dialog', { name: 'Service version changed' })
|
||||
// Changed versions show only the versions.
|
||||
expect(dialog.textContent).toContain('Service0.2.20.2.3Page0.2.10.2.3')
|
||||
expect(dialog.textContent).toContain('After the connection was re-established, versions changed as follows.')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reload to load the new version' }))
|
||||
expect(reloadPage).toHaveBeenCalledOnce()
|
||||
expect(acceptRelease).not.toHaveBeenCalled()
|
||||
const keep = screen.getAllByRole('button', { name: 'Not now' })
|
||||
// The footer action and the dialog's close control both keep the page.
|
||||
expect(keep).toHaveLength(2)
|
||||
for (const button of keep) fireEvent.click(button)
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(acceptRelease).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('shows commits for a transition within one version and labels what the Host did not publish', () => {
|
||||
prompt({
|
||||
page: { version: '0.2.0' },
|
||||
current: { version: '0.2.1', commit: 'aaaaaaa' },
|
||||
next: { version: '0.2.1', commit: 'bbbbbbb' },
|
||||
})
|
||||
expect(screen.getByRole('dialog').textContent).toContain('Serviceaaaaaaabbbbbbb' + 'Page0.2.00.2.1')
|
||||
cleanup()
|
||||
prompt({ page: { version: '0.2.1' }, current: { version: '0.2.1' }, next: { version: '0.2.1', commit: 'bbbbbbb' } }, zh)
|
||||
expect(screen.getByRole('dialog', { name: '服务版本变化' }).textContent)
|
||||
.toContain('服务未标注bbbbbbb页面未标注bbbbbbb')
|
||||
cleanup()
|
||||
prompt({ page: undefined, current: undefined, next: { version: '0.2.1' } })
|
||||
expect(screen.getByRole('dialog').textContent).toContain('ServiceNot labeled0.2.1PageNot labeled0.2.1')
|
||||
cleanup()
|
||||
prompt({ page: { version: '0.2.1' }, current: { version: '0.2.1' }, next: undefined })
|
||||
expect(screen.getByRole('dialog').textContent).toContain('Service0.2.1Not labeledPage0.2.1Not labeled')
|
||||
})
|
||||
})
|
||||
@@ -77,7 +77,7 @@ describe('ui-settings-general shell', () => {
|
||||
}, COLD_BOOT_TIMEOUT_MS)
|
||||
|
||||
it('declares its services', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'configForms', 'shortcuts'])
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'configForms', 'shortcuts', 'modules'])
|
||||
})
|
||||
|
||||
it('occupies sidebar.settings, declared by ui-sidebar, and declares every child slot', async ({ start }) => {
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
{ "path": "../connection/tsconfig.client.json" },
|
||||
{ "path": "../../api/remotes/tsconfig.client.json" },
|
||||
{ "path": "../ui-sidebar" },
|
||||
{ "path": "../shortcuts/tsconfig.client.json" }
|
||||
{ "path": "../shortcuts/tsconfig.client.json" },
|
||||
{ "path": "../modules" },
|
||||
{ "path": "../ui-layout" }
|
||||
]
|
||||
}
|
||||
@@ -3237,6 +3237,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
'client-ui-schedule ScheduleDeleteToast id \'schedule.delete-toast\'',
|
||||
'client-ui-settings-account DesktopOnboardingEntry id \'desktop-onboarding\'',
|
||||
'client-ui-settings-account AccountPlatformHost id \'account.platform-page\'',
|
||||
'client-ui-settings-general HostReleasePrompt id \'host-release-change\'',
|
||||
'client-ui-settings-session-log UploadToast id \'session-log-upload-toast\'',
|
||||
'client-ui-shortcuts ShortcutReference id \'shortcuts\'',
|
||||
'client-ui-workspace SessionRenameDialog id \'workspace.session-rename\'',
|
||||
|
||||
@@ -8434,7 +8434,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'WebBootGraph',
|
||||
declaration: 'export interface WebBootGraph {\n rev: string;\n entries: WebBootEntry[];\n batches: WebBootBatch[];\n}',
|
||||
declaration: 'export interface WebBootGraph {\n rev: string;\n release?: WebBootRelease;\n entries: WebBootEntry[];\n batches: WebBootBatch[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'WebBootRelease',
|
||||
declaration: 'export interface WebBootRelease {\n version: string;\n commit?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'WebFetchBody',
|
||||
|
||||
Generated
+6
@@ -4977,6 +4977,9 @@ importers:
|
||||
'@deepseek-ai/dsh-client-locale':
|
||||
specifier: workspace:*
|
||||
version: link:../locale
|
||||
'@deepseek-ai/dsh-client-modules':
|
||||
specifier: workspace:*
|
||||
version: link:../modules
|
||||
'@deepseek-ai/dsh-client-shortcuts':
|
||||
specifier: workspace:*
|
||||
version: link:../shortcuts
|
||||
@@ -4986,6 +4989,9 @@ importers:
|
||||
'@deepseek-ai/dsh-client-test-runtime':
|
||||
specifier: workspace:*
|
||||
version: link:../../test-support/client-runtime
|
||||
'@deepseek-ai/dsh-client-ui-layout':
|
||||
specifier: workspace:*
|
||||
version: link:../ui-layout
|
||||
'@deepseek-ai/dsh-client-ui-primitives':
|
||||
specifier: workspace:*
|
||||
version: link:../ui-primitives
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
assertClientBuildEnvironment,
|
||||
clientBuildEnvironmentDefines,
|
||||
clientBuildProcessEnvironment,
|
||||
clientBuildValueDefines,
|
||||
officialClientBuildEnvironment,
|
||||
readClientBuildRecord,
|
||||
repositoryClientBuildEnvironment,
|
||||
@@ -216,20 +217,23 @@ describe('client build environment', () => {
|
||||
})
|
||||
|
||||
it('defines only public client values over a non-enumerable fallback', () => {
|
||||
expect(clientBuildEnvironmentDefines({
|
||||
const environment = {
|
||||
PATH: '/bin',
|
||||
DSH_TEST_API_KEY: 'secret',
|
||||
DSH_CLIENT_VARIANT: 'quoted "value"',
|
||||
DSH_CLIENT_EMPTY: '',
|
||||
DSH_CLIENT_UNSET: undefined,
|
||||
})).toEqual({
|
||||
'process.env': '{}',
|
||||
}
|
||||
const values = {
|
||||
'process.env.DSH_CLIENT_EMPTY': '""',
|
||||
'process.env.DSH_CLIENT_VARIANT': '"quoted \\"value\\""',
|
||||
})
|
||||
}
|
||||
expect(clientBuildEnvironmentDefines(environment)).toEqual({ 'process.env': '{}', ...values })
|
||||
// Node faces keep their runtime `process.env` and fix only the public values.
|
||||
expect(clientBuildValueDefines(environment)).toEqual(values)
|
||||
})
|
||||
|
||||
it('feeds the same build-process value to dynamic tsdown bundles and the Vite shell', async () => {
|
||||
it('feeds the same build-process value to dynamic tsdown bundles, their Node faces, and the Vite shell', async () => {
|
||||
process.env[PROBE_NAME] = 'shared-value'
|
||||
|
||||
const configs = clientBundle(join(import.meta.dirname, '../packages/client/ui-sidebar'), [
|
||||
@@ -241,6 +245,9 @@ describe('client build environment', () => {
|
||||
'process.env': '{}',
|
||||
[PROBE_KEY]: '"shared-value"',
|
||||
})
|
||||
const node = configs.find(config => config.name === '@deepseek-ai/dsh-client-ui-sidebar')
|
||||
expect(node?.define).toMatchObject({ [PROBE_KEY]: '"shared-value"' })
|
||||
expect(node?.define).not.toHaveProperty(['process.env'])
|
||||
|
||||
const viteConfigPath = '../apps/web/vite.config.ts'
|
||||
const viteModule: unknown = await import(viteConfigPath)
|
||||
|
||||
@@ -265,7 +265,19 @@ export function assertClientBuildEnvironment(
|
||||
export function clientBuildEnvironmentDefines(
|
||||
environment: NodeJS.ProcessEnv,
|
||||
): Record<string, string> {
|
||||
const defines: Record<string, string> = { 'process.env': '{}' }
|
||||
return { 'process.env': '{}', ...clientBuildValueDefines(environment) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Node-face substitutions that fix each public client build value into the artifact.
|
||||
* `process.env` itself stays untouched, so Host code keeps reading its other variables at runtime.
|
||||
* @param environment - environment inherited by the build process.
|
||||
* @returns one deterministic tsdown `define` expression per public value the build sets.
|
||||
*/
|
||||
export function clientBuildValueDefines(
|
||||
environment: NodeJS.ProcessEnv,
|
||||
): Record<string, string> {
|
||||
const defines: Record<string, string> = {}
|
||||
for (const [name, value] of Object.entries(clientBuildEnvironment(environment))) {
|
||||
defines[`process.env.${name}`] = JSON.stringify(value)
|
||||
}
|
||||
|
||||
@@ -2006,6 +2006,11 @@
|
||||
"symbol": "WebBootBatch",
|
||||
"source": "packages/client/modules/src/client/manifest.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/client-modules.md",
|
||||
"symbol": "WebBootRelease",
|
||||
"source": "packages/client/modules/src/client/manifest.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/client-modules.md",
|
||||
"symbol": "WebBootGraph",
|
||||
|
||||
Reference in new issue
Block a user