feat(web): apply client plugin changes without reloading

This commit is contained in:
Yichen Jiang committed 2026-09-14 21:47:38 +08:00
1 parent 4f7336c831
commit fb0fb48033
59 files changed
+1273 -236

No files matched your search

@@ -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 .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
2026-07-23-client-plugin-loading-model.md: d0f9b20f0adabda6cc7132e5411bcadd60c9e108
2026-07-23-client-plugin-loading-model.zh.md: 27d5026ed03509d6408a16389246cb1321a37959
2026-07-23-client-plugin-loading-model.md: 924459599a63070fc4a3cc89d32ea0e18b936dc2
2026-07-23-client-plugin-loading-model.zh.md: ab8a19ee92601e6e5038ed764802084cd8082b6f
@@ -68,25 +68,31 @@ Why is the roster yml rows and not a scan? Because which plugins compose into a
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 framework-free loading page projects real fiber states via `internal/status`. After the sweep, the kernel calls `ctx.uiRenderer.mount(container)` and replaces the page with the real UI in one pass.
### Live graph reconciliation
The modules controller owns exactly the Loader entries created from the boot manifest. Full Host snapshots update its module descriptors and reconcile those entries; other Loader contributors retain ownership. New modules arrive through single-resource URLs because replaying a startup batch could register an existing factory twice. Removal uses Loader deletion, then drains the captured fiber before removing unused modules and styles. Declared and observed transitive requests keep shared modules alive.
The Host SSE adapter waits for Loader imports, activation and captured removed-fiber cleanup, then sends the complete current graph, including on reconnect. Graph reconciliation and rebuilt code share one page-owned queue. Local generations prevent stale downloads from mounting; opaque revisions are compared only for equality. The settings inventory reports page-local errors and can retry the same graph without changing Host enablement. This preserves unrelated page state and avoids both an application restart and a second plugin executor. Electron's independent installation flow remains outside this mechanism.
### Hot reload: one driver plugin, self-watched bundles
Hot reload is a composition decision: the web bundle mounts the `client-hmr` row (a normal plugin package) unconditionally; its node half brings the bundle watch and the SSE channel, and the chain stays idle until a rebuild watcher rewrites client bundles. A composition that must not expose it disables the row.
How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. Before reading each startup snapshot, the module host captures the bundle stat baseline and exposes it through `ctx.clientModules.artifactBaseline(id)`. One HMR-owned interval compares every current graph row with that baseline. An unchanged row starts watching without a content read or hash; a write after baseline capture is already a stat delta and only that row enters `rebuilt(id)`. This avoids both an initial all-row re-hash and `fs.watchFile`, whose asynchronous first baseline can silently absorb a construction-time rebuild. Watch membership follows `onGraphChanged`; vanished rows drop out, and a bundle missing at poll time keeps its row dirty so reappearance forces a re-hash even with identical metadata. On a bundle mtime or size delta, or a dirty row, `rebuilt(id)` is the single re-hash entry point; it reads the current source map as part of that new artifact snapshot, while a map-only write does not remount unchanged executable code. When the `rev` actually changed, the node half 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. Polling is deliberate because inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`; one bundle stat per row and interval is sufficient, the interval is a validated config field (default 500ms), and disposal clears the one timer. Rebuilding artifacts is any tsdown watch process's business — `scripts/dev-web.ts` remains the watch-build entry point, discovering its package list through `dsh.client` while scanning `packages/*/*/package.json` at startup — and builder and host share zero protocol. A torn bundle read self-heals: its stats keep changing while the write completes, so the next poll re-hashes and broadcasts the final rev.
On the browser side, the driver reloads one plugin per frame, serialized:
On the browser side, the transport delegates code replacement to the same modules controller that owns graph reconciliation:
1. `invalidate` — drop the stale factory and record, and bind the rebuilt frame's revision to that row's one-resource combo URL. A live factory would make the next step a no-op.
2. `prefetch` — load that one-resource external script and register the fresh factory while the old fiber still serves. The initial multi-resource script never executes again.
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.
6. Materialize the new exports through the module system, then call `entry.refresh()` to mount them through Loader. CSS re-injects after the old disposers drain; explicit materialization exposes the import error before Loader can reduce it to a console log.
7. `fiber.await()` — rethrows loud.
Every plugin shares 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 foundational provider such as connection re-loads every dependent through cordis itself — 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. Static assembly packages and the shell kernel are not entries: changing them means a shell rebuild and a full page reload. Reload has no rollback: 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.
Reload creates a fresh fiber and component state; it does not preserve React state inside the replaced plugin. Static assembly libraries and the shell require a rebuilt page. Failed imports or activation remain diagnosable and retryable without rolling back unrelated plugins. Self-reload closes the old SSE channel and opens a new one; its full snapshot repairs missed graph changes. Boot, graph updates and rebuilt frames share the same queue, so a code replacement cannot overlap initial module arrival.
## Package ownership
@@ -68,25 +68,31 @@ Host 会快照每个已构建插件产物,并把每个调度阶段的有序 ro
4. `settled` = 每个 entry 已创建 + `loader.await()` 完全停稳 + 一次全 ACTIVE 扫描。扫描列出每个 import 失败、FAILED 或 PENDING 的 fiber 及其缺失的服务。它存在的理由:cordis 的 inject 等待没有超时——这次扫描就是大声失败的兜底线。
5. 不依赖框架的 loading 页经 `internal/status` 投影真实 fiber 状态。检查完成后,内核调用 `ctx.uiRenderer.mount(container)`,一次切换到真实 UI。
### 动态图对账
modules 控制器只持有从启动清单创建的 Loader 条目。Host 的完整快照更新模块描述并对账这些条目;其他 Loader 贡献方保留自身所有权。新增模块通过单资源 URL 到达,因为重放启动 batch 可能重复注册现有 factory。移除使用 Loader 删除语义,随后等待已捕获 fiber 清理完毕,再移除未使用的模块与样式。已声明及已观察到的传递依赖使共享模块保持存活。
Host SSE 适配器等待 Loader 导入、激活及已捕获的被移除 fiber 清理完成后发送当前完整图,重连也使用同一路径。图对账与代码重建共用一个页面队列。本地代际阻止过期下载挂载;不透明 revision 只比较相等。失败页面报告本地错误,并可重试同一张图而不改变 Host 启用状态。这保留了无关页面状态,也无需重启应用或引入第二套插件执行器。Electron 的独立安装流程不属于此机制。
### 热重载:一个驱动插件,自行监视的 bundle
热重载是一项组合决策:web 组合包无条件挂载 `client-hmr` 行(一个常规的插件包),其 node 半带来 bundle 监视与 SSE(Server-Sent Events)通道;没有重建 watcher 改写客户端 bundle 时链路保持空闲。不应暴露它的组合可以禁用该行。
重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。模块 host 在读取每份启动快照前捕获 bundle 的 stat 基线,并通过 `ctx.clientModules.artifactBaseline(id)` 暴露它。HMR 自持的单个定时器把当前图的每个 row 与这份基线比较:未变化的 row 直接开始监视,不读取内容也不求哈希;基线捕获后的写入已经形成 stat 差异,只有该 row 会进入 `rebuilt(id)`。这同时消除了启动期的全量重哈希,并避开 `fs.watchFile` 以异步首次 stat 建立基线、可能静默吸收构造期重建的问题。监视集合的成员随 `onGraphChanged` 更新;消失的 row 撤下监视,轮询时缺失的 bundle 则让对应 row 保持标脏状态,文件重现时即使元数据相同也强制重哈希。Bundle 的 mtime 或 size 变化,或 row 处于标脏状态时,`rebuilt(id)` 是重哈希的唯一入口;它会在新产物快照中一并读取当前 source map,而仅写入 map 不会重新挂载未变化的可执行代码。`rev` 真正变化时,node 半才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;每个 row 每个间隔只需一次 bundle stat,轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建产物是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。
浏览器侧,驱动插件每帧重载一个插件,串行执行:
浏览器侧的传输把代码替换交给负责图对账的同一个 modules 控制器:
1. `invalidate`——丢弃陈旧的 factory 与记录,并把 rebuilt 帧的 revision 绑定到该 row 的单资源 combo URL。Factory 还活着会让下一步变成 no-op。
2. `prefetch`——加载该单资源外部脚本并登记新 factory,旧 fiber 此刻仍在服役。初始多资源脚本不会再次执行。
3. `registry.delete`——先于任何 fiber 操作。裸做 fiber dispose 会触发 vendored Loader 的自 dispose 分支,把 entry 永久停用。
4. 排空旧 fiber 的各 disposer。
5. 移除名下的 `<style data-plugin>` 标签。
6. `entry.refresh()`——重新 import,物化新工厂。CSS 在这里重新注入,沿用同一批稳定标签 id。
6. 通过模块系统物化新导出,再调用 `entry.refresh()` 由 Loader 挂载。CSS 在旧 disposer 清理完成后重新注入;显式物化使导入错误能够被捕获,而不只留下 Loader 的控制台日志。
7. `fiber.await()`——让失败大声重抛。
每个插件都共享同一套语义;`immediately` 行的重载与 lazy 行分毫不差。依赖级联不花一行 client 代码:fiber 的激活纪元串接着它各服务提供方的 uid,因此替换 connection 等基础 provider 的 fiber 时,每个依赖方都会经 cordis 本身重新装载——行为正确,但代价较高。
支持边界,如实陈述。重载粒度刻意做粗:全新 fiber、全新组件、React 状态丢失、数据层不动——react-refresh 级的状态保留与「重执行 bundle 即重跑 factory」相冲突,属刻意不做。静态装配包与外壳内核不是 entry:改动它们意味着外壳重建加整页刷新。重载不做回滚:import 失败让 entry 失去 fiber,下一个 rebuilt 帧从头重试;apply 失败留下 FAILED fiber 交给状态投影;两者都大声记录。自我重载可行——在途的重载在旧 bundle 的闭包里跑完,新的 apply 再开一条新 SSE 通道——但空窗期到达的帧会丢失,下次重建会再次通知。一处已知的仅限 dev 竞态:rebuilt 帧与仍在途的 boot 到达重叠时共享那次到达的任务,可能物化重建前的字节;下一帧自愈。
重载会创建新的 fiber 和组件状态,不保留被替换插件内部的 React 状态。静态组装库与应用壳需要重建后的页面。导入或激活失败仍可诊断和重试,不会回滚无关插件。自重载关闭旧 SSE 通道并打开新通道;其完整快照补齐遗漏的图变更。启动、图更新和重建帧共用同一队列,因此代码替换不会与初始模块到达重叠。
## 包归属
+131
View File
@@ -0,0 +1,131 @@
/** Real profile, Remote, bundle scripts and Cordis slots: page-local client lifecycle without navigation. */
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import { chromium, type Page } from 'playwright'
import { expect, it, onTestFailed } from 'vitest'
import { launchWebScaffold, watchConsole, captureStableAria, compareOrRefreshGolden, webSnapshotMode } from './scaffold.ts'
import { saveFailureShot, ZH_BROWSER_LOCALE } from './support.ts'
const FIXTURE = fileURLToPath(new URL('./fixtures/plugins/fixture-live-client', import.meta.url))
const EXPECTED = fileURLToPath(new URL('./expected/client-plugin-live', import.meta.url))
async function openInventory(page: Page, url: string) {
await page.goto(url, { waitUntil: 'load' })
await page.getByRole('button', { name: '设置', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.getByRole('button', { name: '插件', exact: true }).click()
await dialog.getByRole('tab', { name: '插件列表', exact: true }).click()
await dialog.getByRole('searchbox', { name: '搜索插件' }).waitFor()
return dialog
}
it('synchronizes two pages, disposes effects and restores an offline page from the latest graph without navigation', async () => {
const scaffold = await launchWebScaffold({
extraInstallAnchors: [join(FIXTURE, 'package.json')],
})
const host = scaffold.ctx.loader.ctx.fiber.uid
const browser = await chromium.launch()
try {
const context = await browser.newContext({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
const otherContext = await browser.newContext({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
const page = await context.newPage()
const other = await otherContext.newPage()
const consoles = [watchConsole(page), watchConsole(other)]
onTestFailed(() => saveFailureShot(page, 'web-e2e-client-live'))
await openInventory(page, scaffold.authenticatedUrl)
const otherInventory = await openInventory(other, scaffold.authenticatedUrl)
let entryId: string | undefined
const toggle = async () => {
if (entryId === undefined) entryId = await scaffold.ctx.loader.create({ name: '@fixture/live-client' })
else { scaffold.ctx.loader.remove(entryId); entryId = undefined }
}
let navigations = 0
for (const target of [page, other]) target.on('framenavigated', () => { navigations++ })
const live = (target: Page) => target.locator('[data-live-client]')
const dataset = (target: Page) => target.evaluate(() => ({
liveMounts: document.documentElement.dataset.liveMounts,
liveDisposals: document.documentElement.dataset.liveDisposals,
liveHits: document.documentElement.dataset.liveHits,
}))
const ping = (target: Page) => target.evaluate(() => { window.dispatchEvent(new Event('dsh-fixture-ping')) })
expect(await live(page).count()).toBe(0)
expect(scaffold.ctx.clientModules.graph().entries.some(row => row.id === '@fixture/live-client')).toBe(false)
await toggle()
for (const target of [page, other]) {
await live(target).waitFor()
expect(await live(target).evaluate(el => getComputedStyle(el).color)).toBe('rgb(12, 34, 56)')
await ping(target)
expect((await dataset(target)).liveHits).toBe('1')
}
await compareOrRefreshGolden(join(EXPECTED, 'enabled.expected.md'), await captureStableAria(page, '[data-live-client]', scaffold.workspaceCwd), webSnapshotMode())
// The inventory filter is page-owned state that live composition must preserve.
const draft = otherInventory.getByRole('searchbox', { name: '搜索插件' })
await draft.fill('unfinished-filter')
await toggle()
for (const target of [page, other]) {
await expect.poll(() => live(target).count()).toBe(0)
await expect.poll(async () => (await dataset(target)).liveDisposals).toBe('1')
await ping(target)
expect((await dataset(target)).liveHits).toBe('1')
expect(await target.locator('style[data-plugin="@fixture/live-client"]').count()).toBe(0)
}
expect(await draft.inputValue()).toBe('unfinished-filter')
await toggle()
for (const target of [page, other]) {
await live(target).waitFor()
expect(await live(target).count()).toBe(1)
expect((await dataset(target)).liveMounts).toBe('2')
expect(await target.locator('style[data-plugin="@fixture/live-client"]').count()).toBe(1)
}
const disconnected = other.waitForEvent('requestfailed', request => request.url().includes('/plugins/events'))
await otherContext.setOffline(true)
// Chromium offline emulation leaves established SSE sockets open. Cycling this
// fixture's transport plugin closes them without replacing the Host or profile.
const transport = [...scaffold.ctx.loader.entries()].find(entry => entry.options.name === '@deepseek-ai/dsh-client-hmr')!.fiber!
const reconnected = page.waitForResponse(response => response.url().includes('/plugins/events') && response.status() === 200)
await transport.restart()
await disconnected
await reconnected
await toggle()
await expect.poll(() => live(page).count()).toBe(0)
expect(await live(other).count()).toBe(1)
await otherContext.setOffline(false)
await expect.poll(() => live(other).count(), { timeout: 20_000 }).toBe(0)
expect(await draft.inputValue()).toBe('unfinished-filter')
expect(navigations).toBe(0)
expect(scaffold.ctx.loader.ctx.fiber.uid).toBe(host)
for (const console of consoles) expect(console.pageErrors).toEqual([])
} finally {
await browser.close()
await scaffold.close()
}
}, 90_000)
it('keeps a failed client download local and retries without changing Host enablement', async () => {
const scaffold = await launchWebScaffold({ extraInstallAnchors: [join(FIXTURE, 'package.json')] })
const browser = await chromium.launch()
try {
const page = await browser.newPage({ locale: ZH_BROWSER_LOCALE })
onTestFailed(() => saveFailureShot(page, 'web-e2e-client-live-retry'))
await openInventory(page, scaffold.authenticatedUrl)
const bundle = (url: URL) => url.pathname.startsWith('/plugins/') && url.search.includes('@fixture/live-client/client.js')
await page.route(bundle, route => route.abort())
const entryId = await scaffold.ctx.loader.create({ name: '@fixture/live-client' })
const failure = page.locator('[data-client-sync-failure]')
await failure.waitFor()
expect(scaffold.ctx.loader.resolve(entryId).fiber?.state).toBe(2)
expect(await page.locator('[data-live-client]').count()).toBe(0)
expect(scaffold.ctx.clientModules.graph().entries.some(row => row.id === '@fixture/live-client')).toBe(true)
await page.unroute(bundle)
await failure.getByRole('button', { name: '重试本页面同步' }).click()
await page.locator('[data-live-client]').waitFor()
await expect.poll(() => failure.count()).toBe(0)
expect(scaffold.ctx.loader.resolve(entryId).fiber?.state).toBe(2)
} finally {
await browser.close()
await scaffold.close()
}
}, 90_000)
@@ -0,0 +1 @@
- text: 动态插件已启用
@@ -0,0 +1,31 @@
/** Test package using the published registration protocol, locale and slots. */
window.__ModuleLoader__.load({
id: '@fixture/live-client',
factory(require) {
const React = require('react')
const style = document.createElement('style')
style.dataset.plugin = '@fixture/live-client'
style.textContent = '[data-live-client] { color: rgb(12, 34, 56); position: absolute; bottom: 20px; right: 20px; }'
document.head.append(style)
return {
inject: ['slots', 'locale'],
apply(ctx) {
const counters = document.documentElement.dataset
counters.liveMounts = String(Number(counters.liveMounts ?? 0) + 1)
ctx.effect(() => ctx.locale.register('fixtureLive', { zh: { active: '动态插件已启用' }, en: { active: 'Live plugin enabled' } }))
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
name: 'shell.overlay', id: 'fixture-live-client', locale: 'fixtureLive',
}, ({ t }) => React.createElement('div', { 'data-live-client': '' }, t('active'))))
ctx.effect(() => {
const ping = () => { counters.liveHits = String(Number(counters.liveHits ?? 0) + 1) }
window.addEventListener('dsh-fixture-ping', ping)
return async () => {
window.removeEventListener('dsh-fixture-ping', ping)
await Promise.resolve()
counters.liveDisposals = String(Number(counters.liveDisposals ?? 0) + 1)
}
})
},
}
},
})
@@ -0,0 +1,3 @@
- insert:
- id: fixture-live-client
name: '@fixture/live-client'
@@ -0,0 +1,2 @@
/** Host companion for the live browser lifecycle fixture. */
export function apply() {}
@@ -0,0 +1,13 @@
{
"name": "@fixture/live-client",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "index.js",
"exports": { ".": "./index.js", "./client": "./client.js" },
"dsh": {
"title": "动态界面示例",
"bundle": { "patch": "./cordis.patch.yml" },
"client": { "platform": "web" }
}
}
+1
View File
@@ -46,6 +46,7 @@
"tests/details-session-lifecycle.e2e.ts",
"tests/document-preview.e2e.ts",
"tests/plugin-config.e2e.ts",
"tests/client-plugin-live.e2e.ts",
"tests/settings-chrome.e2e.ts",
"tests/models-settings.e2e.ts",
"tests/deepseek-messages-settings.e2e.ts",
+2 -2
View File
@@ -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 docs/config-catalog.md
config-catalog.md: 134926329e9320e38b6370e45138cbfccb180baa
config-catalog.zh.md: 60cfa69460173ddfd587a86349280c00486ec80a
config-catalog.md: 44b5ec950dc69091bd2b31bbe94b639d6fa99e85
config-catalog.zh.md: c4587edfcb0b6ec04db931820c0920a69555d922
+1 -1
View File
@@ -428,7 +428,7 @@ Source: [`packages/client/connection/src/index.ts:72`](../packages/client/connec
## `@deepseek-ai/dsh-client-hmr`
Requires: `clientModules` · `webServer`
Requires: `clientModules` · `webServer` · `loader`
```ts config-catalog
/** Plugin config, validated by the same-named schemastery schema. */
+1 -1
View File
@@ -430,7 +430,7 @@ export interface ConnectionRecoveryConfig {
## `@deepseek-ai/dsh-client-hmr`
需要:`clientModules` · `webServer`
需要:`clientModules` · `webServer` · `loader`
```ts config-catalog
/** Plugin config, validated by the same-named schemastery schema. */
+2 -2
View File
@@ -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 docs/subsystems/client-modules.md
client-modules.md: 12925fa8ddb7c7193af76cf1bed5894f5d7d75e0
client-modules.zh.md: 513391af8369a5ba4d9a439a14f7fb5eb1f45d27
client-modules.md: 73246153466e716a13a90d5c63e10c139d00974e
client-modules.zh.md: babb205e409b75ffe0f49095baa08dba18019ea7
+1 -1
View File
@@ -100,7 +100,7 @@ interface ClientArtifactBaseline {
`ClientModuleRegistry` (`ctx.clientModules`, defined in [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts)) exposes reads and the rebuild face; signatures are in the generated [service catalog](#ctxclientmodules--clientmoduleregistry). `graph()` returns the current composed graph (a stable object between changes), `clientPath(id)` returns the bundle's absolute path, and `artifactBaseline(id)` returns the bundle stat values captured before the current snapshot was read. `rebuilt(id)` is the only entry point through which changed bundle content reaches the graph: it re-hashes the bundle together with its current source map, and only a real rev change recomposes the graph and notifies. `onRebuilt` fires per changed bundle with the new rev; `onGraphChanged` fires after any flush that recomposed the graph (row added or removed, or a rebuilt rev change) and is pull-model — listeners re-read `graph()`. Both notification paths contain listener exceptions so one throwing subscriber cannot skip later subscribers or kill whatever triggered the flush.
In development, [dsh-client-hmr](../../packages/client/hmr/README.md) is the registry's watch driver: its node half stat-polls every graph row's bundle from the module host's pre-read baseline, calls `rebuilt(id)` only for a changed or dirty row, resyncs its watch set through `onGraphChanged`, and broadcasts rev changes to the browser half over SSE. Source-map changes alone do not trigger a reload; the current map joins the snapshot when a bundle change does. Production graphs omit the HMR row entirely; the module host itself never watches files.
[`dsh-client-hmr`](../../packages/client/hmr/README.md) delivers live graph snapshots in the shipped Web composition. The Host waits for Loader imports, activation and captured removed-fiber cleanup before publication, and reconnect sends the current full graph. Its artifact poll separately reports rebuilt revisions. Client Modules validates snapshots and serializes reconciliation with those rebuilds; it owns the boot-created entry map, single-resource arrivals, asynchronous removal, unused-module/style cleanup and page-local retry status. Static platform modules and the bootstrap retain their page lifetime; Electron installation is a separate flow.
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
+1 -1
View File
@@ -100,7 +100,7 @@ interface ClientArtifactBaseline {
`ClientModuleRegistry`(`ctx.clientModules`,定义于 [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts))暴露读取面与重建面;签名见生成的[服务目录](#ctxclientmodules--clientmoduleregistry)。`graph()` 返回当前组合出的图(两次变更之间是同一个稳定对象),`clientPath(id)` 返回 bundle 的绝对路径,`artifactBaseline(id)` 返回读取当前快照前捕获的 bundle stat 值。`rebuilt(id)` 是变化后的 bundle 内容到达图的唯一入口:它把 bundle 与当前 source map 一起重新哈希,只有 rev 真正变化才会重新组合图并发出通知。`onRebuilt` 按发生变化的 bundle 逐个触发并携带新 rev;`onGraphChanged` 在任何一次重新组合了图的 flush 之后触发(行的增删,或 rebuilt 带来的 rev 变化),并采用拉取模型——监听器自行重读 `graph()`。两条通知路径都会兜住监听器异常,因此一个抛错的订阅者既不能让后续订阅者被跳过,也不能杀死触发这次 flush 的一方。
开发环境下,[dsh-client-hmr](../../packages/client/hmr/README.zh.md) 是注册表的监视驱动:它的 Node 半从 module host 读文件前记录的基线出发,对图中每一行的 bundle 做 stat 轮询,只为变化或标脏的 row 调用 `rebuilt(id)`,经 `onGraphChanged` 重新同步监视集合,并通过 SSE(Server-Sent Events)把 rev 变化广播给浏览器半。仅 source map 变化不会触发重载;bundle 变化时,当前 map 会一起进入快照。生产环境的图完全不含 HMR(热模块替换)行;module host 自身从不监视文件。
随包提供的 Web 组合通过 [`dsh-client-hmr`](../../packages/client/hmr/README.zh.md) 交付动态图快照。Host 等待 Loader 导入、激活及已捕获的被移除 fiber 清理完成后再发布,重连会发送当前完整图。产物轮询另外报告重建 revision。Client Modules 校验快照,并将对账与重建串行协调;它持有启动创建的条目映射,负责单资源到达、异步移除、未使用模块与样式清理,以及页面本地重试状态。静态平台模块与 bootstrap 保持页面生命周期;Electron 安装属于独立流程。
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
+2 -2
View File
@@ -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 packages/client/hmr/README.md
README.md: 26bc640c6cdd213fbc78de920e12444589901990
README.zh.md: 72d941f678e7e29a56bca678a79c4740667e7e6b
README.md: e0e00cca9f3856ab68a22b1de9b93fe8abe249ee
README.zh.md: 31bb5bfd103bb342d5d7af710afa7350f40c7c80
+8 -8
View File
@@ -1,5 +1,5 @@
---
description: "Development-only hot reload for browser client plugins: rebuilding a plugin bundle swaps the running plugin in place, for developers iterating on the web GUI."
description: "Live graph synchronization and development bundle reloads for Web client plugins."
kind: "package-reference"
---
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
`dsh-client-hmr` reloads a browser client plugin in place when its bundle is rebuilt, so a developer editing plugin source sees the change without a full page reload. The reload chain stays idle without a rebuild watcher: only a `pnpm run dev:web`-style process rewriting client bundles produces the rebuilds it reacts to. Each reload swaps one plugin with fresh component state while the data layer (connection, runtime, and Session objects) stays untouched. Everything here is development machinery in the browser; the model never sees it.
`dsh-client-hmr` keeps open Web pages in sync with the Host plugin graph and reloads rebuilt browser bundles. Ordinary plugin enable/disable changes take effect without reloading the page or restarting the Host. Code rebuilds replace the affected plugin with fresh component state. The model sees no new input or output.
## Table of Contents
@@ -25,7 +25,7 @@ English | [中文](README.zh.md)
<a id="use-this-package"></a>
## Use this package
Enable the rebuild watcher for the plugin you are editing, then save: the browser picks up the rebuilt bundle from the dev server and swaps the plugin without reloading the page. Use it during client development; nothing observable happens in a production build, where no watcher rewrites bundles.
The shipped Web composition mounts this transport for live plugin changes. During development, a bundle watcher also supplies code rebuilds. Disabling the transport stops graph delivery to open pages.
### Starting the reload chain
@@ -59,11 +59,11 @@ This section explains how the reload chain is built; observable behavior is cove
### Design concept
The chain is two halves with one contract: the node half owns bundle detection and notification, the browser half owns the swap. The node half runs one interval that stat-polls each graph bundle from the module host's pre-read baseline. An unchanged startup row starts watching without a content read or hash; a changed row, or a dirty row whose artifact reappears, enters `rebuilt()`, and only real revision changes are broadcast. `rebuilt()` reads the current source map together with the changed bundle; a map-only write does not reload executable code. The node half also serves `/plugins/events`, an SSE channel broadcasting `graph` and `rebuilt` frames.
The Host half watches bundle artifacts and serves `/plugins/events`. It broadcasts the full current graph after Loader imports, activation and captured removed-fiber cleanup settle, including on connection and reconnection. Dense graph notifications during one recomposition therefore do not publish intermediate rosters. Artifact polling still reports rebuilt revisions; unchanged artifacts require no content read. The browser half delegates both frame kinds to the page-owned controller in Client Modules.
### The browser swap
On a `rebuilt` frame the revision makes `invalidate` select that plugin's immutable one-resource combo URL instead of its initial multi-resource URL. `prefetch` loads and registers the new factory while the old fiber still serves. The remaining order is registry-first teardown (`registry.delete` before the fiber's disposer emits `internal/plugin`, or the vendored Loader flags the entry disabled), drain the old fiber's unload, delete `entry.fiber`, remove owned `<style data-plugin>` tags, then `entry.refresh()` re-imports and remounts, and `fiber.await()` rethrows startup failures loudly. The swap is safe because execution is pure registration under the lazy-CJS model: every module side effect lives in the factory closure and runs at materialization.
On a `rebuilt` frame, the controller invalidates the old module and prefetches its single-resource script while the old fiber still serves. It then deletes the registry runtime, drains the old fiber, clears its entry reference and removes owned styles. The module system materializes the new exports before `entry.refresh()` mounts them through Loader; this exposes import failures to page diagnostics even when Loader would only log them. CSS is injected after old effects have finished.
### Cascade and self-reload
@@ -71,14 +71,14 @@ A fiber's activation epoch strings its service providers' uids, so replacing a p
### Failure policy
No rollback: an import failure leaves the entry fiberless (the next rebuilt frame retries from scratch), and an apply failure leaves a FAILED fiber visible in the shell's status projection. Both log loudly.
No rollback: failed imports and activation remain visible as page-local synchronization errors. Settings → Plugins → Plugin list retries the latest graph, even when its revision is unchanged; successful unrelated plugins remain active.
### Source map
| File | Role |
|---|---|
| [`src/index.ts`](src/index.ts) | Node half: bundle stat-poll, `rebuilt` reporting, `/plugins/events` SSE channel |
| [`src/client/index.ts`](src/client/index.ts) | Browser half: SSE subscription, serialized reload queue, fiber swap |
| [`src/client/index.ts`](src/client/index.ts) | Browser half: SSE subscription and delegation to the shared entry controller |
| [`src/events.ts`](src/events.ts) | Shared frame types (`graph` / `rebuilt`) and the endpoint constant |
</details>
@@ -115,7 +115,7 @@ These limits define what the reload driver does not preserve or restore. They ar
- **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 and is deliberately out.
- **No failure rollback** — a reload that fails leaves the entry FAILED and visible in the loader status projection; the previous bundle is not restored automatically.
- **Rebuilt frames do not replace the boot graph** — each frame carries the plugin-artifact revision needed for its one-resource combo reload; a page reload receives the recomposed startup graph.
- **Web transport only** — Electron installation and backend restart handling do not use this SSE path. Entry reconciliation itself is transport-independent.
<a id="dev-note"></a>
### Dev Note
+8 -8
View File
@@ -1,5 +1,5 @@
---
description: "仅用于开发环境的浏览器客户端插件热重载:重建插件 bundle 后原地替换运行中的插件,供开发者迭代 web GUI。"
description: "Web 客户端插件的动态图同步与开发时 bundle 重载。"
kind: "package-reference"
---
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
`dsh-client-hmr` 会在浏览器客户端插件的 bundle 重建后原地重载该插件,让编辑插件源码的开发者无需整页刷新即可看到变更。如果没有重建 watcher,整条链路保持空闲:只有 `pnpm run dev:web` 之类的进程重写客户端 bundle 时才会产生它所响应的重建。每次重载只替换一个插件并携带全新组件状态,而数据层(连接、运行时与 Session 对象)保持不变。这里的一切都是浏览器侧的开发机制;模型永远看不到它。
`dsh-client-hmr` 让已打开的 Web 页面与 Host 插件图保持同步,并重载重建后的浏览器 bundle。普通插件的启停无需刷新页面或重启 Host 即可生效。代码重建会替换受影响插件并重置其组件状态。模型不会收到新的输入或输出。
## 目录
@@ -25,7 +25,7 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
为正在编辑的插件启用重建 watcher,然后保存:浏览器会从 dev server 拾取重建后的 bundle,并在不重载页面的情况下替换该插件。在客户端开发期间使用它;在生产构建中没有任何可观察行为,因为没有 watcher 会重写 bundle。
随包提供的 Web 组合挂载此传输,以交付插件动态变更。开发时,bundle watcher 还会提供代码重建。停用此传输会停止向已打开的页面交付图更新。
### 启动重载链路
@@ -59,11 +59,11 @@ kind: "package-reference"
### 设计理念
链路分为两半,共用一份约定:node 半侧负责 bundle 检测与通知,浏览器半侧负责替换。node 半侧运行一个 interval,从 module host 读取文件前的基线开始 stat 轮询每个图 bundle。未变化的启动 row 无需读取内容或求 hash 即可开始监视;发生变化的 row,或产物恢复后的 dirty row,会进入 `rebuilt()`,且只广播真实 revision 变更。`rebuilt()` 会把当前 source map 与已变化的 bundle 一起读取;仅写入 map 不会重载可执行代码。node 半侧还提供 `/plugins/events`,一个广播 `graph` 与 `rebuilt` 帧的 SSE(Server-Sent Events)通道。
Host 半侧监听 bundle 产物并提供 `/plugins/events`。它在 Loader 导入、激活及已捕获的被移除 fiber 清理完成后广播当前完整图,连接及重连也走这一路径。因此,同一次重组中的密集图通知不会发布中间清单。产物轮询仍会报告重建 revision;未变化的产物无需读取内容。浏览器半侧将两种帧都交给 Client Modules 中的页面控制器。
### 浏览器侧替换
收到 `rebuilt` 帧后,帧内 revision 会让 `invalidate` 选择该插件不可变的单资源 combo URL,而不是初始多资源 URL。`prefetch` 在旧 fiber 仍在服务时加载并注册新 factory。其余顺序是:先从注册表删除,再拆卸(在 fiber 的 disposer 发出 `internal/plugin` 之前执行 `registry.delete`,否则 vendored Loader 会把该 entry 标为禁用)、等待旧 fiber 卸载完成、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签,然后 `entry.refresh()` 重新导入并挂载,`fiber.await()` 直接把启动失败重新抛出。替换之所以安全,是因为在惰性 CJS 模型下执行只是注册:每个模块副作用都位于 factory 闭包中,在物化时运行。
收到 `rebuilt` 帧后,控制器使旧模块失效,并在旧 fiber 仍然服务时预取其单资源脚本。随后删除注册表 runtime、等待旧 fiber 清理、清除条目中的 fiber 引用并移除自身样式。模块系统先物化新导出,再由 `entry.refresh()` 通过 Loader 挂载;这样即使 Loader 只记录导入错误,页面诊断仍能获得失败原因。CSS 在旧 effect 完成清理后注入。
### 级联与自重载
@@ -71,14 +71,14 @@ fiber 的激活 epoch 会串联其服务提供方的 uid,因此替换提供方
### 失败策略
不回滚:导入失败会让 entry 失去 fiber(下一个 `rebuilt` 帧从头重试),apply 失败则会在外壳的状态投影中留下 FAILED fiber。两者都会输出醒目的错误日志。
不回滚:导入与激活失败会显示为当前页面的同步错误。「设置 → 插件 → 插件列表」会针对最新图重试,即使其 revision 未变化;无关且已成功运行的插件保持活动。
### 源码地图
| 文件 | 职责 |
|---|---|
| [`src/index.ts`](src/index.ts) | node 半侧:bundle stat 轮询、`rebuilt` 上报、`/plugins/events` SSE 通道 |
| [`src/client/index.ts`](src/client/index.ts) | 浏览器半侧:SSE 订阅、串行重载队列、fiber 替换 |
| [`src/client/index.ts`](src/client/index.ts) | 浏览器半侧:SSE 订阅与共享条目控制器调用 |
| [`src/events.ts`](src/events.ts) | 共享帧类型(`graph` / `rebuilt`)与端点常量 |
</details>
@@ -115,7 +115,7 @@ fiber 的激活 epoch 会串联其服务提供方的 uid,因此替换提供方
- **重载有意保持粗粒度**——全新 fiber 与全新组件;被重载插件内的 React 状态会丢失,而数据层(连接 fiber、运行时 fiber、Session 对象)不受影响。react-refresh 级状态保留与重新执行 bundle 冲突,因此有意排除。
- **失败时不回滚**——失败的重载会让该 entry 保持 FAILED 并在 loader 状态投影中可见;系统不会自动恢复先前 bundle。
- **重建帧不会替换启动图**——每个帧都携带单资源 combo 重载所需的插件产物 revision;页面重载时才接收重新组合的启动图。
- **仅负责 Web 传输**——Electron 的安装和后端重启流程不使用此 SSE 路径。条目对账本身不依赖传输。
<a id="dev-note"></a>
### 开发备注
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-hmr",
"description": "Dev-only hot-reload driver for script-loaded client entries: SSE rebuilt frames → invalidate/prefetch → fiber swap through the vendored Loader entry",
"description": "Web client graph synchronization and rebuilt-bundle reload transport",
"version": "0.1.5-rc.2",
"publishConfig": {
"access": "public"
+11 -155
View File
@@ -1,69 +1,6 @@
/**
* 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 reloads the entry's bundle and swaps the cordis
* fiber in place. Every graph entry is a plugin bundle
* — `immediately` rows differ only in stage-one prefetch (a boot
* optimization), so all rostered 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 (load and 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 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:
* 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. `entry.fiber` is additionally cleared 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: 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.
*/
/** Web SSE transport for page-owned client entry reconciliation and rebuilt code replacement. */
import type { Context } from '@deepseek-ai/cordis'
import type { Entry, Loader } from '@deepseek-ai/cordis-plugin-loader'
import type { PluginsEventFrame } from '../events.ts'
import type { PluginsEventParseResult } from '../events.ts'
import { EVENTS_ENDPOINT, parsePluginsEventFrame } from '../events.ts'
export type { PluginsEventFrame } from '../events.ts'
@@ -76,97 +13,16 @@ export const name = 'client-hmr'
export const inject = ['loader', 'modules']
/**
* Registry-first teardown of an entry's running fiber so `entry.refresh()`
* rebuilds it (see the module comment): delete the runtime record before the
* fiber's disposer emits `internal/plugin` (or the Loader flags the entry
* disabled), drain the unload so effect disposers finish before a new apply
* re-registers, then clear `entry.fiber` so `refresh()` re-imports instead of
* no-oping. A fiberless entry is left untouched.
* @param entry - the Loader entry to tear down.
*/
export async function tearDownEntryFiber(entry: Entry): Promise<void> {
const oldFiber = entry.fiber
if (oldFiber === undefined) return
const runtime = oldFiber.runtime
if (runtime !== null) entry.ctx.registry.delete(runtime.callback)
while (oldFiber.inertia !== undefined) await oldFiber.inertia
delete entry.fiber
}
/** 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.
* Forward graph snapshots and rebuilds to the page's shared serial controller.
* @param ctx - Plugin context with the client module system.
*/
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, rev: 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: script loading registers
// the fresh factory with zero side effects (lazy CJS — module bodies run
// at materialization, not execution).
modLoader.invalidate(id, rev)
await modLoader.prefetch(id)
await tearDownEntryFiber(entry)
// 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 (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, frame.rev)).catch((error: unknown) => {
ctx.logger.error(`client-hmr: reload of "${frame.id}" failed`)
ctx.logger.error(error)
})
break
case 'graph':
// Connect-time snapshot, unused. Each rebuilt frame carries the
// revision that selects the immutable single-resource combo script; the boot
// graph remains the initial-load record until a page reload.
break
default:
// Merge-extensible frame union: unknown frame types from newer hosts
// are ignored by design.
break
}
const entries = ctx.modules.entries
const handle = (frame: Extract<PluginsEventParseResult, { kind: 'frame' }>['frame']): void => {
const run = frame.type === 'graph'
? Promise.resolve().then(() => entries.sync(frame.graph))
: entries.reload(frame.id, frame.rev)
void run.catch((error: unknown) => { ctx.logger.error(error) })
}
ctx.effect(() => {
@@ -176,7 +32,7 @@ export function apply(ctx: Context): void {
try {
value = JSON.parse(event.data) as unknown
} catch {
// Wire boundary: a malformed dev-channel frame is dropped loudly.
// Wire boundary: a malformed transport frame is dropped loudly.
ctx.logger.warn(`client-hmr: unparseable event frame: ${event.data}`)
return
}
+5 -5
View File
@@ -1,5 +1,5 @@
/**
* Wire protocol of the `/plugins/events` dev SSE channel — single source for
* Wire protocol of the `/plugins/events` SSE channel — single source for
* both halves of this package. Frames still cross a wire boundary: the
* browser half validates them at its JSON parse point; sharing the type keeps
* the two ends from drifting, not from parsing.
@@ -7,19 +7,19 @@
import type { WebBootGraph } from '@deepseek-ai/dsh-client-modules'
/** One SSE frame: the full graph on connect, or one rebuilt bundle notice. */
/** One SSE frame: the settled graph on connection or change, or one rebuilt bundle notice. */
export type PluginsEventFrame =
| { type: 'graph'; graph: WebBootGraph }
| { type: 'rebuilt'; id: string; rev: string }
/** Browser wire-parse result: known frame, forward-compatible unknown type, or malformed payload. */
export type PluginsEventParseResult =
| { kind: 'frame'; frame: PluginsEventFrame }
| { kind: 'frame'; frame: { type: 'graph'; graph: unknown } | Extract<PluginsEventFrame, { type: 'rebuilt' }> }
| { kind: 'unknown' }
| { kind: 'invalid' }
/**
* Validate one JSON-decoded SSE payload before it can mutate module state.
* Validate the frame envelope; the module controller parses the complete graph before updating its index.
* @param value - Parsed JSON value from the EventSource message.
* @returns the known frame, an unknown-type marker, or an invalid marker.
*/
@@ -33,7 +33,7 @@ export function parsePluginsEventFrame(value: unknown): PluginsEventParseResult
: { kind: 'invalid' }
case 'graph':
return typeof record.graph === 'object' && record.graph !== null
? { kind: 'frame', frame: { type: 'graph', graph: record.graph as WebBootGraph } }
? { kind: 'frame', frame: { type: 'graph', graph: record.graph } }
: { kind: 'invalid' }
default:
return typeof record.type === 'string' ? { kind: 'unknown' } : { kind: 'invalid' }
+40 -4
View File
@@ -10,7 +10,7 @@
*/
import { statSync } from 'node:fs'
import type { ServerResponse } from 'node:http'
import type { Context } from '@deepseek-ai/cordis'
import type { Context, Fiber } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
// Type imports carry the clientModules/webServer Context merges.
import type { ClientArtifactBaseline } from '@deepseek-ai/dsh-client-modules'
@@ -24,8 +24,8 @@ export { EVENTS_ENDPOINT } from './events.ts'
/** Cordis plugin name. */
export const name = 'client-hmr'
/** Required services: the web plugin table and the route registry. */
export const inject = ['clientModules', 'webServer']
/** Required services: the client graph, Web route registry and Loader settlement. */
export const inject = ['clientModules', 'webServer', 'loader']
/** Plugin config, validated by the same-named schemastery schema. */
export interface Config {
@@ -158,6 +158,39 @@ export function apply(ctx: Context, config: Config): void {
// --- /plugins/events SSE channel ----------------------------------------
const connections = new Set<ServerResponse>()
const disposing = new Set<Fiber>()
let closed = false
let publishing: Promise<void> | undefined
let dirty = false
const publishGraph = (): void => {
dirty = true
if (publishing !== undefined) return
publishing = (async () => {
do {
dirty = false
await ctx.loader.await()
for (const fiber of disposing) {
while (fiber.inertia !== undefined) await fiber.inertia
disposing.delete(fiber)
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- graph callbacks set dirty while Loader settlement yields.
} while (dirty && !closed)
if (closed) return
const line = sseData({ type: 'graph', graph: ctx.clientModules.graph() })
for (const res of connections) res.write(line)
})().catch((error: unknown) => { ctx.logger.error(error) }).finally(() => {
publishing = undefined
if (dirty && !closed) publishGraph()
})
}
ctx.on('internal/plugin', (fiber) => {
if (fiber.entry === undefined || fiber.uid !== null) return
// Removed fibers disappear from Loader.getTasks() before their effects finish.
disposing.add(fiber)
publishGraph()
})
const connect = (res: ServerResponse): void => {
res.writeHead(200, {
'content-type': 'text/event-stream',
@@ -167,8 +200,8 @@ export function apply(ctx: Context, config: Config): void {
// Comment line on open so clients/proxies see a live channel even when
// no rebuild ever happens; EventSource frame parsing skips it naturally.
res.write(': connected\n\n')
res.write(sseData({ type: 'graph', graph: ctx.clientModules.graph() }))
connections.add(res)
publishGraph()
res.on('close', () => { connections.delete(res) })
}
@@ -187,11 +220,14 @@ export function apply(ctx: Context, config: Config): void {
connect(res)
},
})
const unsubscribeGraph = ctx.clientModules.onGraphChanged(publishGraph)
const unsubscribe = ctx.clientModules.onRebuilt((id, rev) => {
const line = sseData({ type: 'rebuilt', id, rev })
for (const res of connections) res.write(line)
})
return () => {
closed = true
unsubscribeGraph()
unsubscribe()
disposeRoute()
for (const res of connections) res.destroy()
@@ -2,9 +2,12 @@
* Node half of the HMR plugin: bundle watches follow the graph, stat changes
* report through clientModuleHost.rebuilt, and everything dies with the fiber.
*/
import { EventEmitter } from 'node:events'
import type { ServerResponse, IncomingMessage } from 'node:http'
import { mkdtempSync, rmSync, statSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import { Context } from '@deepseek-ai/cordis'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientArtifactBaseline, ClientModuleRegistry, WebBootGraph } from '@deepseek-ai/dsh-client-modules'
@@ -88,6 +91,7 @@ function fakeHttpServer(routes: WebRoute[]): WebServer {
async function mount(clientModuleHost: FakeHost, webServer: WebServer) {
const ctx = new Context()
await ctx.plugin(Loader)
ctx.provide('clientModules', clientModuleHost)
ctx.provide('webServer', webServer)
const fiber = ctx.plugin(
@@ -226,3 +230,79 @@ describe('hmr node half', () => {
await fiber.dispose()
})
})
it('publishes settled graphs, drains removed fibers and reconnects without intermediate rosters', async () => {
const ctx = new Context()
await ctx.plugin(Loader)
const bundle = join(dir, 'a.js')
writeFileSync(bundle, 'a')
const rows = new Map([['a', bundle]])
const host = fakeClientModuleHost(rows)
const routes: WebRoute[] = []
ctx.provide('clientModules', host)
ctx.provide('webServer', fakeHttpServer(routes))
let release!: () => void
let cleaned!: () => void
let started!: () => void
const starting = new Promise<void>((resolve) => { started = resolve })
const activation = new Promise<void>((resolve) => { release = resolve })
const cleanup = new Promise<void>((resolve) => { cleaned = resolve })
ctx.loader.internal = { version: 'client', import: async () => ({
apply: async (pluginCtx: Context) => {
pluginCtx.effect(() => () => cleanup)
started()
await activation
},
}) } as never
const entryId = await ctx.loader.create({ name: 'owned' })
await starting
const fiber = ctx.plugin({ inject, Config, apply }, { pollIntervalMs: POLL_MS })
await fiber.await()
const route = routes[0]!
const connect = async () => {
const lines: string[] = []
const response = Object.assign(new EventEmitter(), {
writeHead: vi.fn(), write: (line: string) => { lines.push(line) },
destroy: vi.fn(), end: vi.fn(),
})
await route.handler({ method: 'GET' } as IncomingMessage, response as unknown as ServerResponse)
return { lines, response }
}
try {
const first = await connect()
rows.clear()
host.fireGraphChanged()
await Promise.resolve()
rows.set('a', bundle)
host.fireGraphChanged()
expect(first.lines).toEqual([': connected\n\n'])
release()
await vi.waitFor(() => { expect(first.lines).toHaveLength(2) })
const frame = JSON.parse(first.lines[1]!.slice(6)) as { graph: WebBootGraph }
expect(frame.graph.entries.map(row => row.id)).toEqual(['a'])
const second = await connect()
await vi.waitFor(() => { expect(second.lines).toHaveLength(2) })
expect(second.lines[1]).toBe(first.lines[1])
first.response.emit('close')
ctx.loader.remove(entryId)
rows.clear()
host.fireGraphChanged()
await Promise.resolve()
await Promise.resolve()
expect(second.lines).toHaveLength(2)
cleaned()
await vi.waitFor(() => { expect(second.lines).toHaveLength(3) })
expect((JSON.parse(second.lines[2]!.slice(6)) as { graph: WebBootGraph }).graph.entries).toEqual([])
await fiber.dispose()
host.fireGraphChanged()
await Promise.resolve()
expect(second.lines).toHaveLength(3)
expect(second.response.destroy).toHaveBeenCalledOnce()
} finally {
release()
cleaned()
await fiber.dispose()
await ctx.fiber.dispose()
}
})
@@ -0,0 +1,42 @@
/** Web transport delegates module and entry changes to the page-owned controller. */
import { Context } from '@deepseek-ai/cordis'
import type { ClientModuleLoader } from '@deepseek-ai/dsh-client-modules/client'
import { afterEach, expect, it, vi } from 'vitest'
import { apply } from '../src/client/index.ts'
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks() })
it('forwards full graphs and rebuilt frames, contains wire errors and closes its EventSource', async () => {
const ctx = new Context()
const sync = vi.fn(async () => {})
const reload = vi.fn(async () => {})
ctx.provide('modules', { entries: { sync, reload } } as unknown as ClientModuleLoader)
const warnings = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const errors = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
let receive!: (event: { data: string }) => void
const close = vi.fn()
vi.stubGlobal('EventSource', class {
close = close
addEventListener(_name: string, listener: typeof receive) { receive = listener }
})
const fiber = ctx.plugin({ apply })
try {
await fiber.await()
const graph = { rev: 'r', entries: [], batches: [] }
receive({ data: JSON.stringify({ type: 'graph', graph }) })
receive({ data: JSON.stringify({ type: 'rebuilt', id: 'a', rev: 'r1' }) })
await vi.waitFor(() =>{ expect(sync).toHaveBeenCalledWith(graph) })
expect(reload).toHaveBeenCalledWith('a', 'r1')
receive({ data: '{' })
receive({ data: JSON.stringify({ type: 'graph', graph: null }) })
receive({ data: JSON.stringify({ type: 'future' }) })
expect(warnings).toHaveBeenCalledTimes(2)
sync.mockRejectedValueOnce(new Error('invalid graph'))
receive({ data: JSON.stringify({ type: 'graph', graph: {} }) })
await vi.waitFor(() =>{ expect(errors).toHaveBeenCalledWith(expect.objectContaining({ message: 'invalid graph' })) })
} finally {
await fiber.dispose()
await ctx.fiber.dispose()
}
expect(close).toHaveBeenCalledOnce()
})
+3 -1
View File
@@ -8,7 +8,9 @@
"DOM",
"DOM.Iterable"
],
"types": ["node"]
"types": [
"node"
]
},
"include": [
"src"
+2 -2
View File
@@ -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 packages/client/modules/README.md
README.md: c83796fc8d6c364b9fad6d44e25316e2c2f7e7af
README.zh.md: 9ffb68ebc07b5970edc45a54c4cfde7cdf4ef7d8
README.md: 2b5aa0e2e3a0a7d591fc573f3d4d70404e759b2b
README.zh.md: d2a46283b50baab3a5ee9d399df87a0cfa4910e0
+9 -1
View File
@@ -37,6 +37,10 @@ A browser plugin package declares `dsh.client` in its `package.json` with `platf
The application combo scripts register plugin factories once during boot; module bodies remain lazy and run only at first import or materialization. Rows that share a combo URL share one in-flight script task. HMR switches one changed row to its revisioned one-resource combo URL. `<id>/client` and the bare id resolve to the same exports, because a plugin bundle is its package's client half.
### Live plugin composition
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.
### Sharing modules
The shell seeds a frozen module table (`PLATFORM_MODULES`: React, Cordis, and static UI libraries); 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.
@@ -75,6 +79,10 @@ The bundle route follows the injected `webServer` lifetime: it registers when th
The host contributes structured index rows that inject, into `<head>`: the `window.__ModuleLoader__` queue facade, advisory preloads for every application combo, the parser-blocking bootstrap combo scripts, then the boot graph before the shell reads it. A Web carrier renders those rows into its index response; a shell-owned carrier can render the same rows without a Web server. The facade's `create()` materializes the modules bundle, delegates construction to its `createClientModuleSystem` export, and leaves the same facade in live-registration mode. The shell installs that returned system as its Loader's `internal`; the modules plugin publishes that instance as `ctx.modules`, so separate Cordis trees never select an instance through module-global state.
### 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 a newer graph arrives. New arrivals use single-resource URLs, never startup batches that could register existing factories twice. 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
| File | Role |
@@ -118,7 +126,7 @@ None; this package neither assembles nor sends a provider request.
These limits define what the module system does not do. They are current package constraints, not a task backlog.
- **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.
- **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.
- **Bootstrap and code replacement limits** — the page retains its modules bootstrap and static platform identities. Removing the bootstrap requires a page reload; replacing package code and all existing consumers is outside ordinary enable/disable synchronization.
- **Snapshot delivery retains artifact bytes** — the Host holds each bundle, optional source map, generated one-resource response, and current startup combo responses in memory; HMR additionally retains one prior startup generation. Memory scales as several copies of the composed client artifacts in exchange for immutable responses and one-generation race tolerance.
<a id="dev-note"></a>
+9 -1
View File
@@ -37,6 +37,10 @@ kind: "package-reference"
application combo 脚本在启动时仅注册一次插件 factory;模块主体仍保持惰性,只在首次 import 或物化时运行。共享 combo URL 的 row 共用一个进行中的脚本任务。HMR(热模块替换)会让一条发生变化的 row 改用带 revision 的单资源 combo URL。`<id>/client` 与裸 id 解析到同一组导出,因为插件 bundle 就是其包的客户端半侧。
### 插件动态组合
已打开的 Web 页面通过 HMR 传输跟随 Host 的完整模块图。启用普通插件会添加其 Loader 条目;停用会移除条目,并在其异步 effect 完成清理后回收未使用的模块与样式。再次启用会加载一个带样式的实例。其他 Loader 贡献方的条目及活动条目仍需使用的共享模块会保留。「设置 → 插件 → 插件列表」显示当前页面的同步失败,并提供不改变 Host 启用状态的重试。
### 共享模块
外壳初始化一张冻结的模块表(`PLATFORM_MODULES`:React、Cordis 与静态 UI 库);每个动态 bundle 都精确针对该基座解析其 external。`dsh.client.external` 只添加基座之外的精确请求;系统会将每个请求解析到其指定的动态包 row 或完全匹配的静态表键。纯类型 import 会被擦除,不产生请求。组合阶段会拒绝畸形请求、缺失提供方、自请求与同步请求环。
@@ -75,6 +79,10 @@ bundle 路由随注入的 `webServer` 生命周期注册:服务就绪时注册
宿主贡献结构化 index 行,并向 `<head>` 注入:`window.__ModuleLoader__` queue facade、每个 application combo 的提示性 preload、阻塞 parser 的 bootstrap combo 脚本,然后才是外壳读取前的启动图。Web 载体把这些行渲染进 index 响应;由 shell 持有的载体则可以在没有 Web server 时渲染同一批行。facade 的 `create()` 物化 modules bundle、把构造委托给其 `createClientModuleSystem` 导出,并让同一 facade 进入 live registration 模式。外壳把返回的系统装成自身 Loader 的 `internal`;modules 插件将该实例发布为 `ctx.modules`,因此不同 Cordis 树不会通过模块级全局状态选择实例。
### 条目所有权
`ClientEntries` 记录启动时创建的条目,并在同一个 Loader 上串行执行完整图更新、重试和代码重载。本地代际阻止旧下载在新图到达后挂载。新增模块使用单资源 URL,不会重新执行可能重复注册现有 factory 的启动 batch。清理会保留每个剩余 Loader 条目的已声明及已观察到的传递模块依赖。其可观察状态不导入运行时库,因为 modules bootstrap 在平台种子可用之前物化。
### 源码索引
| 文件 | 职责 |
@@ -118,7 +126,7 @@ bundle 路由随注入的 `webServer` 生命周期注册:服务就绪时注册
这些限制说明模块系统不做什么。它们是当前包约束,不是任务积压。
- **有意采用扁平模块图**——每个 bundle 是一个模块节点,其边只指向表中的叶节点;接口(`loadCache`/`edges`/`invalidate`)已经支持通用模块图,因此可以改变 externalization 粒度而不更改接口。
- **自身不维护卸载记录**——样式移除与 fiber 拆卸顺序属于 HMR 驱动器(`@deepseek-ai/dsh-client-hmr`);loader 只在每条记录中登记其拥有的样式标签 id。
- **Bootstrap 与代码替换限制**——页面保留 modules bootstrap 和静态平台模块的身份。移除 bootstrap 需要刷新页面;替换包代码及其所有现有消费者不属于普通启停同步。
- **快照式提供会保留产物字节**——Host 在内存中保留每个 bundle、可选 source map、生成的单资源响应和当前启动 combo 响应;HMR 还会保留上一代启动响应。内存会随已组合客户端产物增长为数份副本,以换取不可变响应和一代竞态容忍。
<a id="dev-note"></a>
+2 -1
View File
@@ -46,7 +46,8 @@
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-package-manifest": "workspace:^"
"@deepseek-ai/dsh-package-manifest": "workspace:^",
"@deepseek-ai/dsh-client-store": "workspace:^"
},
"files": [
"lib/index.js",
@@ -0,0 +1,230 @@
/** Page-owned Loader entries; transport-independent reconciliation, retries and code replacement. */
import type { FiberState } from '@deepseek-ai/cordis'
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 { removeOwnedStyles, tearDownEntryFiber } from './entry-lifecycle.ts'
/** Page-local failures do not change the Host's bundle enablement. */
export interface ClientEntryState {
/** True while a snapshot, retry or code replacement is being applied. */
readonly syncing: boolean
/** Package ids and errors from the latest reconciliation. */
readonly failures: readonly { readonly id: string; readonly message: string }[]
}
/** Internal capabilities owned by the module table, called only after serialized entry operations. */
interface ModuleIndex {
update(manifest: BootManifest): void
prune(roots: Iterable<string>): void
}
/** Numeric values mirror Cordis's const enum, which bundle loaders cannot import as a runtime object. */
const ACTIVE = 2 as FiberState.ACTIVE
const FAILED = 3 as FiberState.FAILED
/** Manages only entries created from the Host manifest; other Loader contributors retain ownership. */
export class ClientEntries {
/** Stable observable consumed by page diagnostics through the renderer's injected hook. */
readonly state: ObservableSnapshot<ClientEntryState> = {
getSnapshot: () => this.snapshot,
subscribe: (listener) => {
this.listeners.add(listener)
return () => { this.listeners.delete(listener) }
},
}
// 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>()
private readonly managed = new Map<string, Entry>()
private readonly revisions = new Map<string, string>()
private loader: Loader | undefined
private queue: Promise<void> = Promise.resolve()
private desired: BootManifest
private generation = 0
private stopped = false
/**
* Construct the page controller before Cordis boot.
* @param modules - Module arrival and materialization owner.
* @param index - Private descriptor replacement and unused-module cleanup.
*/
constructor(private readonly modules: ClientModuleLoader, private readonly index: ModuleIndex) {
this.desired = modules.manifest
}
/**
* Create the initial roster and retain its entry identities for subsequent reconciliation.
* @param loader - Page Loader, already configured with the module system.
* @param manifest - Initial roster audited by the boot caller.
* @returns after initial entries and their activation settle; boot owns its activation audit.
*/
start(loader: Loader, manifest: BootManifest): Promise<void> {
if (this.loader !== undefined) throw new Error('client-modules: entries already started')
this.loader = loader
this.desired = manifest
loader.ctx.effect(() => () => {
this.stopped = true
this.generation++
return this.queue
}, 'client-modules: entry reconciliation')
return this.enqueue(async () => {
await Promise.all(this.desired.plugins.map(async ({ id }) => {
await this.create(loader, id)
}))
await loader.await()
for (const row of this.modules.manifest.modules) this.revisions.set(row.id, row.rev)
})
}
/**
* Validate and apply the latest full Host graph. A later snapshot prevents an older download from mounting.
* @param graph - JSON-decoded graph received from the Host.
* @returns after the queued reconciliation; per-package failures remain available in {@link state}.
*/
sync(graph: unknown): Promise<void> {
this.desired = parseBootManifest(graph)
const generation = ++this.generation
return this.enqueue(() => this.reconcile(generation))
}
/**
* Retry failed entries against the latest graph, including an unchanged revision.
* @returns after retry settlement, with remaining errors in {@link state}.
*/
retry(): Promise<void> {
const generation = ++this.generation
return this.enqueue(() => this.reconcile(generation))
}
/**
* Replace one entry's code in the same queue as graph updates; duplicate revisions are ignored.
* @param id - Package id from a rebuilt frame.
* @param rev - Opaque revision selecting the rebuilt artifact.
* @returns after replacement settles; failure rejects and is exposed in page diagnostics.
*/
reload(id: string, rev: string): Promise<void> {
this.desired = {
...this.desired,
modules: this.desired.modules.map(row => row.id === id ? { ...row, rev } : row),
}
return this.enqueue(async () => {
if (this.stopped || !this.desired.modules.some(row => row.id === id)) return
const entry = this.managed.get(id)
if (entry === undefined || this.revisions.get(id) === rev) return
this.publish({ syncing: true, failures: this.snapshot.failures.filter(failure => failure.id !== id) })
await this.replace(entry, id, rev, this.generation)
this.publish({ syncing: false, failures: this.snapshot.failures })
}, id)
}
private publish(snapshot: ClientEntryState): void {
this.snapshot = snapshot
for (const listener of [...this.listeners]) {
try {
listener()
} catch (error) {
console.error('client-modules: synchronization subscriber failed', error)
}
}
}
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.
this.queue = run.then(() => undefined, (error: unknown) => {
this.publish({ syncing: false, failures: [
...this.snapshot.failures.filter(failure => failure.id !== subject),
{ id: subject, message: String(error) },
] })
})
return run
}
private current(generation: number): boolean {
return !this.stopped && generation === this.generation
}
/** Keep ownership even when Loader rejects a module's plugin exports after inserting its entry. */
private async create(loader: Loader, id: string): Promise<void> {
const options = { name: id }
const entryId = loader.ensureId(options)
try {
await loader.create(options)
} finally {
this.managed.set(id, loader.resolve(entryId))
}
}
private async replace(entry: Entry, id: string, rev: string, generation: number): Promise<void> {
this.modules.invalidate(id, rev)
await this.modules.prefetch(id)
if (!this.current(generation)) return
await tearDownEntryFiber(entry)
removeOwnedStyles(id)
if (!this.current(generation)) return
await this.modules.import(id, '', {})
if (!this.current(generation)) return
await entry.refresh()
await entry.fiber?.await()
if (entry.fiber === undefined) throw new Error(`client-modules: ${id} import failed (see console)`)
this.revisions.set(id, rev)
}
private async reconcile(generation: number): Promise<void> {
if (!this.current(generation)) return
const loader = this.loader
if (loader === undefined) throw new Error('client-modules: entries have not started')
const manifest = this.desired
this.publish({ syncing: true, failures: [] })
const failures: { id: string; message: string }[] = []
this.index.update(manifest)
const wanted = new Set(manifest.plugins.map(row => row.id))
for (const [id, entry] of this.managed) {
if (wanted.has(id)) continue
const fiber = entry.fiber
loader.remove(entry.id)
this.managed.delete(id)
this.revisions.delete(id)
// Removed fibers no longer appear in Loader.getTasks().
while (fiber?.inertia !== undefined) await fiber.inertia
}
for (const row of manifest.modules) {
if (!this.current(generation)) break
try {
const entry = this.managed.get(row.id)
if (entry === undefined) {
await this.modules.prefetch(row.id)
if (!this.current(generation)) break
await this.modules.import(row.id, '', {})
if (!this.current(generation)) break
await this.create(loader, row.id)
this.revisions.set(row.id, row.rev)
} else if (this.revisions.get(row.id) !== row.rev) {
await this.replace(entry, row.id, row.rev, generation)
} else if (entry.fiber === undefined) {
await this.replace(entry, row.id, row.rev, generation)
} else if (entry.fiber.state === FAILED) {
entry.fiber.update(entry.options.config)
}
} catch (error) {
failures.push({ id: row.id, message: String(error) })
}
}
await loader.await()
for (const [id, entry] of this.managed) {
if (failures.some(failure => failure.id === id)) continue
if (entry.fiber?.state === ACTIVE) continue
try {
if (entry.fiber === undefined) throw new Error(`client-modules: ${id} import failed (see console)`)
await entry.fiber.await()
failures.push({ id, message: `client-modules: ${id} is waiting for activation` })
} catch (error) {
failures.push({ id, message: String(error) })
}
}
this.index.prune([...loader.entries()].map(entry => entry.options.name))
if (this.current(generation)) this.publish({ syncing: false, failures })
}
}
@@ -0,0 +1,28 @@
/** Loader lifecycle operations shared by live graph reconciliation and code replacement. */
import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
/**
* Release a runtime before clearing its entry fiber so Loader refresh can import new code.
* Registry deletion prevents Loader from treating replacement as a user disable.
* @param entry - Entry retained for code replacement.
*/
export async function tearDownEntryFiber(entry: Entry): Promise<void> {
const fiber = entry.fiber
if (fiber === undefined) return
const runtime = fiber.runtime
/* v8 ignore next -- Loader entries own plugin fibers; only the root context has a null runtime. */
if (runtime !== null) entry.ctx.registry.delete(runtime.callback)
while (fiber.inertia !== undefined) await fiber.inertia
delete entry.fiber
}
/**
* Remove styles after their plugin's effect cleanup has settled.
* @param id - Package whose factory owns the style tags.
*/
export function removeOwnedStyles(id: string): void {
if (typeof document === 'undefined') return
for (const el of document.querySelectorAll('style[data-plugin]')) {
if (el.getAttribute('data-plugin') === id) el.remove()
}
}
@@ -19,6 +19,8 @@ import type {
} from './manifest.ts'
export { ClientModuleSystem }
export { tearDownEntryFiber } from './entry-lifecycle.ts'
export type { ClientEntries, ClientEntryState } from './entries.ts'
export { exactPackageSpecifier, parseBootManifest, parseDshClient, stripClientSuffix } from './manifest.ts'
export type {
BootManifest, BootModuleRow, BootPluginRow, ClientBootstrapModule, ClientBundleRegistration,
@@ -31,6 +31,7 @@
import type {} from '@deepseek-ai/cordis'
import type { DshClientManifest } from '@deepseek-ai/dsh-package-manifest'
import type { ClientEntries } from './entries.ts'
import type { ClientModuleSystem } from './system.ts'
declare module '@deepseek-ai/cordis' {
@@ -373,6 +374,8 @@ export interface ClientModuleLoader {
version: 'client'
/** Parsed Host boot graph shared with the web entry after module-system creation. */
manifest: BootManifest
/** Page-owned entry reconciliation, shared by boot, graph updates and HMR. */
entries: ClientEntries
/** Materialized-module registry: id → record. The governance-side read API for entry exports. */
loadCache: Map<string, ClientModuleRecord>
/**
+44 -2
View File
@@ -5,6 +5,8 @@
* state tables and the load/materialize machinery.
*/
import { stripClientSuffix } from './manifest.ts'
import { ClientEntries } from './entries.ts'
import { removeOwnedStyles } from './entry-lifecycle.ts'
import type {
BootManifest, BootModuleRow, ClientBundleRegistration, ClientModuleLoader, ClientModuleRecord,
ClientModuleSystemOptions,
@@ -60,7 +62,8 @@ const claimStyles = (id: string): string[] => {
*/
export class ClientModuleSystem implements ClientModuleLoader {
readonly version = 'client'
readonly manifest: BootManifest
manifest: BootManifest
readonly entries: ClientEntries
readonly loadCache = new Map<string, ClientModuleRecord>()
private readonly seed: Map<string, unknown>
@@ -81,11 +84,14 @@ export class ClientModuleSystem implements ClientModuleLoader {
*/
constructor(options: ClientModuleSystemOptions) {
this.manifest = options.manifest
this.entries = new ClientEntries(this, {
update: (manifest) => { this.updateManifest(manifest) },
prune: (roots) => { this.prune(roots) },
})
this.seed = new Map(Object.entries(options.staticModules))
this.loadBundle = options.loadBundle ?? defaultLoadBundle
for (const row of options.manifest.modules) {
if (this.graphRows.has(row.id)) throw new Error(`client-modules: duplicate graph entry "${row.id}"`)
this.graphRows.set(row.id, row)
}
@@ -186,6 +192,9 @@ export class ClientModuleSystem implements ClientModuleLoader {
const record: ClientModuleRecord = { id, exports, styles: claimStyles(id), edges }
this.loadCache.set(id, record)
return record
} catch (error) {
removeOwnedStyles(id)
throw error
} finally {
this.materializing.delete(id)
}
@@ -237,6 +246,39 @@ export class ClientModuleSystem implements ClientModuleLoader {
await this.arriveGraphRow(row)
}
/** Replace descriptors without invalidating live factories; subsequent arrivals use individual resources. */
private updateManifest(manifest: BootManifest): void {
for (const id of this.bootstrapIds) {
if (this.manifest.modules.some(row => row.id === id) && !manifest.modules.some(row => row.id === id)) {
throw new Error(`client-modules: removing bootstrap module ${id} requires a page reload`)
}
}
for (const row of manifest.modules) this.graphRows.set(row.id, { ...row, initialUrl: row.url })
this.manifest = manifest
}
/** Retain live Loader modules and their transitive requests before evicting unreferenced graph records. */
private prune(roots: Iterable<string>): void {
const retained = new Set<string>(this.bootstrapIds)
const visit = (specifier: string): void => {
const id = stripClientSuffix(specifier)
if (retained.has(id)) return
retained.add(id)
const row = this.graphRows.get(id)
for (const request of [...row?.external ?? [], ...row?.inject ?? [], ...this.loadCache.get(id)?.edges ?? []]) {
visit(request)
}
}
for (const row of this.manifest.modules) visit(row.id)
for (const id of roots) visit(id)
for (const id of this.graphRows.keys()) {
if (retained.has(id)) continue
this.graphRows.delete(id)
this.invalidate(id)
removeOwnedStyles(id)
}
}
invalidate(id: string, rev?: string): void {
const normalized = stripClientSuffix(id)
if (this.bootstrapIds.has(normalized)) return
@@ -0,0 +1,393 @@
// @vitest-environment jsdom
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createClientModuleSystem } from '../src/client/index.ts'
import type { ClientBundleRegistration, ClientModuleLoaderTarget, WebBootEntry, WebBootGraph } from '../src/client/index.ts'
const contexts: Context[] = []
afterEach(async () => {
for (const ctx of contexts.splice(0)) {
await ctx.fiber.dispose()
await ctx.fiber.await()
}
document.head.querySelectorAll('style').forEach((el) =>{ el.remove() })
document.body.replaceChildren()
vi.restoreAllMocks()
})
const row = (id: string, rev = 'r0', extra: Partial<WebBootEntry> = {}): WebBootEntry => ({
id, rev, url: `/plugins/??${id}/client.js&rev=${rev}`, ...extra,
})
const graph = (...entries: WebBootEntry[]): WebBootGraph => ({
rev: JSON.stringify(entries), entries,
batches: entries.length === 0 ? [] : [{ phase: 'application', url: '/batch', rev: 'batch', entries: entries.map(row => row.id) }],
})
const deferred = () => {
let resolve!: () => void
const promise = new Promise<void>((done) => { resolve = done })
return { promise, resolve }
}
async function bench(initial: WebBootGraph, factories: Record<string, ClientBundleRegistration['factory']>, start = true) {
const ctx = new Context()
contexts.push(ctx)
const fetched: string[] = []
const target: ClientModuleLoaderTarget = {
mode: 'queue', pendingQueue: [], load: () => {},
create: options => createClientModuleSystem(target, { id: 'bootstrap', exports: { apply() {} } }, options),
}
let arrival: (url: string) => Promise<void> = async () => {}
const modules = target.create({
boot: initial, staticModules: {},
loadBundle: async (url) => {
fetched.push(url)
await arrival(url)
const ids = url === '/batch' ? initial.entries.map(row => row.id) : [url.split('??')[1]!.split('/client.js')[0]!]
for (const id of ids) target.load({ id, factory: factories[id]! })
},
})
await ctx.plugin(Loader)
ctx.loader.internal = modules as never
if (start) await modules.entries.start(ctx.loader, modules.manifest)
return { ctx, modules, fetched, target, arrival: (fn: typeof arrival) => { arrival = fn } }
}
/** A visible plugin whose style and listener are owned by its factory and fiber respectively. */
function visible(id: string, effects: { mounted: number; disposed: number; hits: number }, cleanup?: () => Promise<void>) {
return () => {
const style = document.createElement('style')
style.dataset.plugin = id
style.textContent = `[data-live="${id}"] { color: rgb(12, 34, 56); }`
document.head.append(style)
return { apply(ctx: Context) {
ctx.effect(() => {
effects.mounted++
const el = document.createElement('div')
el.dataset.live = id
document.body.append(el)
const listener = () => { effects.hits++ }
window.addEventListener('live-test', listener)
return async () => {
window.removeEventListener('live-test', listener)
el.remove()
await cleanup?.()
effects.disposed++
}
})
} }
}
}
describe('client manifest entries', () => {
it('adds, drains removal and re-enables one instance with styles; unrelated entries survive', async () => {
const effects = { mounted: 0, disposed: 0, hits: 0 }
const cleanup = deferred()
const b = await bench(graph(), { pet: visible('pet', effects, () => cleanup.promise) })
b.target.load({ id: 'local', factory: () => ({ apply() {} }) })
const localId = await b.ctx.loader.create({ name: 'local' })
await b.modules.entries.sync(graph(row('pet')))
expect(b.fetched).toEqual([row('pet').url])
expect(document.querySelectorAll('[data-live=pet]')).toHaveLength(1)
expect(document.querySelectorAll('style[data-plugin=pet]')).toHaveLength(1)
window.dispatchEvent(new Event('live-test'))
expect(effects.hits).toBe(1)
const removing = b.modules.entries.sync(graph())
await vi.waitFor(() =>{ expect(document.querySelector('[data-live=pet]')).toBeNull() })
const readding = b.modules.entries.sync(graph(row('pet')))
expect(effects.disposed).toBe(0)
expect(effects.mounted).toBe(1)
cleanup.resolve()
await Promise.all([removing, readding])
expect(effects).toEqual({ mounted: 2, disposed: 1, hits: 1 })
expect(document.querySelectorAll('style[data-plugin=pet]')).toHaveLength(1)
expect(b.ctx.loader.resolve(localId).fiber?.state).toBe(2)
await b.modules.entries.sync(graph())
window.dispatchEvent(new Event('live-test'))
expect(effects).toEqual({ mounted: 2, disposed: 2, hits: 1 })
expect(document.querySelectorAll('style[data-plugin=pet]')).toHaveLength(0)
expect(b.modules.loadCache.has('pet')).toBe(false)
expect(b.modules.entries.state.getSnapshot()).toEqual({ syncing: false, failures: [] })
})
it('does not mount an obsolete download and can load it again later', async () => {
const effects = { mounted: 0, disposed: 0, hits: 0 }
const b = await bench(graph(), { pet: visible('pet', effects) })
const arrival = deferred()
const started = deferred()
b.arrival(async () => { started.resolve(); await arrival.promise })
const enabling = b.modules.entries.sync(graph(row('pet')))
await started.promise
const disabling = b.modules.entries.sync(graph())
arrival.resolve()
await Promise.all([enabling, disabling])
expect(effects.mounted).toBe(0)
expect(b.modules.loadCache.has('pet')).toBe(false)
await b.modules.entries.sync(graph(row('pet')))
expect(effects.mounted).toBe(1)
expect(b.fetched).toEqual([row('pet').url, row('pet').url])
})
it('registers dynamic dependencies first and retains one still required by an unmanaged entry', async () => {
const materialized = vi.fn<(id: string) => void>()
const b = await bench(graph(row('existing')), {
existing: () => { materialized('existing'); return { apply() {} } },
dependency: () => { materialized('dependency'); return { apply() {}, value: 42 } },
consumer: (require) => {
materialized('consumer')
expect(require('dependency/client')).toHaveProperty('value', 42)
return { apply() {} }
},
})
await b.modules.entries.sync(graph(row('existing'), row('consumer', 'r0', { external: ['dependency/client'] }), row('dependency')))
expect(b.fetched.slice(1)).toEqual([row('dependency').url, row('consumer').url])
expect(materialized.mock.calls.map(([id]) => id)).toEqual(['existing', 'consumer', 'dependency'])
b.target.load({ id: 'local', factory: require => ({ apply() {}, dep: require('dependency/client') }) })
const localId = await b.ctx.loader.create({ name: 'local' })
await b.modules.entries.sync(graph(row('existing')))
expect(b.modules.loadCache.has('consumer')).toBe(false)
expect(b.modules.loadCache.has('dependency')).toBe(true)
b.ctx.loader.remove(localId)
await b.modules.entries.retry()
expect(b.modules.loadCache.has('dependency')).toBe(false)
expect(materialized.mock.calls.filter(([id]) => id === 'existing')).toHaveLength(1)
})
it('diagnoses download and apply failures, retries an identical graph and leaves healthy plugins running', async () => {
const effects = { mounted: 0, disposed: 0, hits: 0 }
let broken = true
const b = await bench(graph(row('healthy')), {
healthy: visible('healthy', effects),
bad: () => ({ apply() { if (broken) throw new Error('apply unavailable') } }),
})
b.arrival(async () => { throw new Error('download unavailable') })
await b.modules.entries.sync(graph(row('healthy'), row('bad')))
expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('download unavailable')
b.arrival(async () => {})
await b.modules.entries.retry()
expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('apply unavailable')
broken = false
await b.modules.entries.retry()
expect(b.modules.entries.state.getSnapshot().failures).toEqual([])
expect(effects.mounted).toBe(1)
expect(effects.disposed).toBe(0)
})
it('deduplicates rebuilt and graph revisions and retries after failed code arrival', async () => {
const effects = { mounted: 0, disposed: 0, hits: 0 }
const b = await bench(graph(row('pet')), { pet: visible('pet', effects) })
await b.modules.entries.reload('pet', 'r1')
await b.modules.entries.sync(graph(row('pet', 'r1')))
expect(effects).toEqual({ mounted: 2, disposed: 1, hits: 0 })
b.arrival(async () => { throw new Error('offline') })
await expect(b.modules.entries.reload('pet', 'r2')).rejects.toThrow('offline')
expect(effects.mounted).toBe(2)
b.arrival(async () => {})
await b.modules.entries.retry()
expect(effects).toEqual({ mounted: 3, disposed: 2, hits: 0 })
expect(document.querySelectorAll('style[data-plugin=pet]')).toHaveLength(1)
})
it('rejects malformed graphs before mutating active entries', async () => {
const b = await bench(graph(row('a')), { a: () => ({ apply() {} }) })
expect(() => b.modules.entries.sync({ rev: 'bad', entries: [{ id: 'a' }], batches: [] })).toThrow('string id/url/rev')
expect([...b.ctx.loader.entries()].map(entry => entry.options.name)).toEqual(['a'])
expect(() => b.modules.entries.start(b.ctx.loader, b.modules.manifest)).toThrow('already started')
})
})
it('publishes stable local snapshots and contains a failing subscriber', async () => {
const b = await bench(graph(), {})
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
const listener = vi.fn()
const removeBad = b.modules.entries.state.subscribe(() => { throw new Error('subscriber') })
const remove = b.modules.entries.state.subscribe(listener)
expect(b.modules.entries.state.getSnapshot()).toBe(b.modules.entries.state.getSnapshot())
await b.modules.entries.retry()
expect(listener).toHaveBeenCalledTimes(2)
expect(error).toHaveBeenCalled()
removeBad()
remove()
await b.modules.entries.retry()
expect(listener).toHaveBeenCalledTimes(2)
})
it('ignores superseded queued snapshots and rebuilds of absent or unchanged entries', async () => {
const b = await bench(graph(), { a: () => ({ apply() {} }) })
const first = b.modules.entries.sync(graph(row('a')))
const second = b.modules.entries.sync(graph())
await Promise.all([first, second])
expect(b.fetched).toEqual([])
await b.modules.entries.reload('a', 'r1')
await b.modules.entries.sync(graph(row('a')))
await b.modules.entries.reload('a', 'r0')
await b.ctx.fiber.dispose()
await b.modules.entries.reload('a', 'r2')
await b.modules.entries.sync(graph())
expect(b.fetched).toHaveLength(1)
})
it('reports reconciliation before startup and missing service activation without losing later retries', async () => {
const b = await bench(graph(), { pending: () => ({ inject: ['missing'], apply() {} }) }, false)
await expect(b.modules.entries.sync(graph())).rejects.toThrow('have not started')
await b.modules.entries.start(b.ctx.loader, b.modules.manifest)
await b.modules.entries.sync(graph(row('pending')))
expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('waiting for activation')
b.ctx.provide('missing', {})
await b.modules.entries.retry()
expect(b.modules.entries.state.getSnapshot().failures).toEqual([])
})
it('retries a materialization failure with the same graph and cleans its partial styles', async () => {
let broken = true
const b = await bench(graph(), { a: () => {
const style = document.createElement('style')
style.dataset.plugin = 'a'
document.head.append(style)
if (broken) throw new Error('factory failed')
return { apply() {} }
} })
await b.modules.entries.sync(graph(row('a')))
expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('factory failed')
expect(document.querySelectorAll('style[data-plugin=a]')).toHaveLength(0)
await b.modules.entries.retry()
expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('factory failed')
await b.modules.entries.reload('a', 'r1').catch(() => {})
expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('factory failed')
broken = false
await b.modules.entries.retry()
expect(b.modules.entries.state.getSnapshot().failures).toEqual([])
expect(document.querySelectorAll('style[data-plugin=a]')).toHaveLength(1)
})
it('does not finish a stale code replacement after download or asynchronous teardown', async () => {
const effects = { mounted: 0, disposed: 0, hits: 0 }
const cleanup = deferred()
const b = await bench(graph(row('a')), { a: visible('a', effects, () => cleanup.promise) })
const download = deferred()
const started = deferred()
b.arrival(async () => { started.resolve(); await download.promise })
const rebuilding = b.modules.entries.reload('a', 'r1')
await started.promise
const snapshot = b.modules.entries.sync(graph(row('a')))
download.resolve()
await Promise.all([rebuilding, snapshot])
expect(effects.mounted).toBe(1)
const swapping = b.modules.entries.reload('a', 'r2')
await vi.waitFor(() =>{ expect(document.querySelector('[data-live=a]')).toBeNull() })
const disabling = b.modules.entries.sync(graph())
cleanup.resolve()
await Promise.all([swapping, disabling])
expect(effects.mounted).toBe(1)
expect(b.modules.loadCache.has('a')).toBe(false)
})
it('stops an obsolete multi-entry application after awaiting removal', async () => {
const cleanup = deferred()
const effects = { mounted: 0, disposed: 0, hits: 0 }
const b = await bench(graph(row('a')), { a: visible('a', effects, () => cleanup.promise), b: () => ({ apply() {} }) })
const first = b.modules.entries.sync(graph(row('b')))
await vi.waitFor(() =>{ expect(document.querySelector('[data-live=a]')).toBeNull() })
const latest = b.modules.entries.sync(graph())
cleanup.resolve()
await Promise.all([first, latest])
expect(b.fetched).toEqual(['/batch'])
})
it('keeps bootstrap ownership explicit and diagnoses removal without changing entries', async () => {
const b = await bench(graph(row('bootstrap')), {})
await expect(b.modules.entries.sync(graph())).rejects.toThrow('removing bootstrap module')
expect([...b.ctx.loader.entries()].map(entry => entry.options.name)).toEqual(['bootstrap'])
expect(b.modules.entries.state.getSnapshot().syncing).toBe(false)
await b.modules.entries.sync(graph(row('bootstrap')))
expect(b.modules.entries.state.getSnapshot().failures).toEqual([])
})
it('retains unrelated style tags while reporting a failed replacement', async () => {
let broken = false
const effects = { mounted: 0, disposed: 0, hits: 0 }
const b = await bench(graph(row('a')), { a: () => {
if (broken) throw new Error('changed factory failed')
return visible('a', effects)()
} })
const unrelated = document.createElement('style')
unrelated.dataset.plugin = 'unrelated'
document.head.append(unrelated)
broken = true
await b.modules.entries.sync(graph(row('a', 'r1')))
expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('changed factory failed')
expect(unrelated.isConnected).toBe(true)
})
it('keeps unrelated page failures visible when a rebuilt download fails', async () => {
const b = await bench(graph(row('a'), row('bad')), {
a: () => ({ apply() {} }),
bad: () => ({ apply() { throw new Error('bad apply') } }),
})
await b.modules.entries.retry()
b.arrival(async () => { throw new Error('a download') })
await expect(b.modules.entries.reload('a', 'r1')).rejects.toThrow('a download')
expect(b.modules.entries.state.getSnapshot().failures.map(failure => failure.id)).toEqual(['bad', 'a'])
})
it('retains a rejected Loader entry for retry instead of creating an orphan sibling', async () => {
let broken = true
const b = await bench(graph(), { a: () => broken ? { default: 'invalid' } : { apply() {} } })
await b.modules.entries.sync(graph(row('a')))
const entry = [...b.ctx.loader.entries()][0]!
expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('invalid plugin')
expect([...b.ctx.loader.entries()]).toHaveLength(1)
broken = false
await b.modules.entries.retry()
expect([...b.ctx.loader.entries()]).toEqual([entry])
expect(entry.fiber?.state).toBe(2)
})
it('reports Loader import failures and recovers the same entry after the importer recovers', async () => {
const b = await bench(graph(), { a: () => ({ apply() {} }) })
let broken = true
b.ctx.loader.internal = {
version: 'client', import: async (id: string) => {
if (broken) throw new Error('Loader import failed')
return b.modules.import(id)
},
} as never
await b.modules.entries.sync(graph(row('a')))
expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('import failed')
const entry = [...b.ctx.loader.entries()][0]!
broken = false
await b.modules.entries.retry()
expect(entry.fiber?.state).toBe(2)
broken = true
await expect(b.modules.entries.reload('a', 'r1')).rejects.toThrow('import failed')
expect(b.modules.entries.state.getSnapshot().failures[0]?.id).toBe('a')
})
it.each([false, true])('does not mount a materialized factory after a newer removal (rebuild: %s)', async (rebuild) => {
let stop: Promise<void> | undefined
let materializations = 0
let mounted = 0
const factories = { a: () => {
materializations++
if (materializations === (rebuild ? 2 : 1)) {
queueMicrotask(() => { stop = b.modules.entries.sync(graph()) })
}
return { apply() { mounted++ } }
} }
const b = await bench(graph(), factories)
if (rebuild) {
// The first materialization remains live until the replacement reaches the import barrier.
materializations = -1
await b.modules.entries.sync(graph(row('a')))
materializations = 1
await b.modules.entries.reload('a', 'r1')
} else {
await b.modules.entries.sync(graph(row('a')))
}
await stop
expect(mounted).toBe(rebuild ? 1 : 0)
expect([...b.ctx.loader.entries()]).toHaveLength(0)
})
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
import { removeOwnedStyles } from '../src/client/entry-lifecycle.ts'
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
@@ -536,6 +537,7 @@ describe('style claiming', () => {
vi.stubGlobal('document', undefined)
try {
await b.loader.import('a', '', {})
removeOwnedStyles('a')
} finally {
vi.unstubAllGlobals()
}
@@ -575,3 +577,27 @@ describe('default transport seam', () => {
expect([...document.querySelectorAll('script')]).toEqual([])
})
})
it('rejects invalid revision URLs and keeps bootstrap exports pinned under invalidation', () => {
const b = bench([row(MODULES_ID), row('a', { url: '/unrevisioned' })])
b.loader.invalidate(MODULES_ID)
expect(b.loader.loadCache.get(MODULES_ID)?.exports).toBe(bootstrapExports)
expect(() =>{ b.loader.invalidate('a', 'next') }).toThrow('has no revision')
})
it('prefetch skips platform requests, cached dependencies and absent optional inject rows', async () => {
const b = bench([
row('a'),
row('b', { external: ['platform', 'a/client'], inject: ['missing'] }),
], { a: () => ({}), b: () => ({}) }, { seed: { platform: {} } })
await b.loader.import('a', '', {})
await b.loader.import('b', '', {})
expect(b.fetched).toEqual([APPLICATION_URL])
})
it('rejects a wire request with no dynamic row or platform supplier at materialization', async () => {
const b = bench([row('a', { external: ['missing'] })], { a: require => ({ value: require('missing') }) })
await expect(b.loader.import('a', '', {})).rejects.toThrow('missed the module table')
})
+6 -5
View File
@@ -8,10 +8,11 @@
},
"include": ["src"],
"references": [
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/loader" },
{ "path": "../../host/webserver" },
{ "path": "../../util/package-manifest" },
{ "path": "../../runtime-diagnostics/invariants" }
{"path": "../../../vendor/cordis"},
{"path": "../../../vendor/loader"},
{"path": "../../host/webserver"},
{"path": "../../util/package-manifest"},
{"path": "../../runtime-diagnostics/invariants"},
{"path": "../store"}
]
}
@@ -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 packages/client/ui-settings-plugin-inventory/README.md
README.md: 40b7e41cd2d6953fdf3b9155fd6adff9d9d65391
README.zh.md: 45f3444ea4fa56fe4a55517eb5d981913c790b15
README.md: bbc8c373e013da9e921370da3293af9b4808db6a
README.zh.md: 0f6b99dcc84bf08bcda1afa21e8dd11fe6d23dfa
@@ -39,6 +39,8 @@ The switcher is the same selector-pill-plus-menu control the General settings ro
A failed read renders a generic failure state inside the tab; retrying re-runs the lazy `list()` call without exposing transport details.
The Plugin list also shows synchronization failures on the current page. Its retry reapplies the latest client graph without changing Host enablement or refreshing the page.
-----
<a id="understand-the-implementation"></a>
@@ -39,6 +39,8 @@ kind: "package-reference"
读取失败会在标签页内渲染通用失败状态;重试会重新执行懒 `list()` 调用,且不会暴露传输细节。
插件列表还显示当前页面的同步失败。重试会重新应用最新客户端图,不改变 Host 启用状态,也不刷新页面。
-----
<a id="understand-the-implementation"></a>
@@ -31,7 +31,8 @@
"@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-agent-preset"
"@deepseek-ai/dsh-client-ui-agent-preset",
"@deepseek-ai/dsh-client-modules"
],
"platform": "web"
}
@@ -58,7 +59,9 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
"@deepseek-ai/dsh-agent-presets": "workspace:^"
"@deepseek-ai/dsh-agent-presets": "workspace:^",
"@deepseek-ai/dsh-client-modules": "workspace:^",
"@deepseek-ai/dsh-client-store": "workspace:^"
},
"files": [
"lib/index.js",
@@ -1,4 +1,6 @@
import { useEffect, useId, useMemo, useState, type ReactNode } from 'react'
import type { ClientEntryState } from '@deepseek-ai/dsh-client-modules/client'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { PluginInventorySnapshot } from '@deepseek-ai/dsh-api-remotes/client'
import {
IconChevronDownOutline14,
@@ -18,6 +20,10 @@ type AgentPresetRow = AgentPresetGroup['rows'][number]
/** Registration-side Remote face used by the section. */
export interface PluginInventorySettingsTabInjected {
/** Page-local module synchronization, independent from the Host inventory. */
hooks: { clientSync: ObservableSnapshot<ClientEntryState> }
/** Retry the latest client graph without changing the Host composition. */
retryClient: () => void
/** Read a current Host inventory snapshot. */
list: () => Promise<PluginInventorySnapshot>
/**
@@ -197,7 +203,10 @@ function StateTag({ kind, label }: { readonly kind: EnablementKind; readonly lab
}
/** Render the read-only plugin inventory: agent presets first, then the global plane. */
export function PluginInventorySettingsTab({ list, presetName, t }: PluginInventorySettingsTabProps): ReactNode {
export function PluginInventorySettingsTab(
{ list, presetName, t, useClientSync, retryClient }: PluginInventorySettingsTabProps,
): ReactNode {
const clientSync = useClientSync(snapshot => snapshot)
const sectionId = useId()
const [request, setRequest] = useState(0)
const [query, setQuery] = useState('')
@@ -375,6 +384,14 @@ export function PluginInventorySettingsTab({ list, presetName, t }: PluginInvent
return (
<div className={css.section} aria-busy={state.status === 'loading'}>
{clientSync.syncing ? <p className={css.status} role="status">{t('clientSyncing')}</p> : null}
{clientSync.failures.length === 0 ? null : (
<div className={css.failure} data-client-sync-failure>
<p role="alert">{t('clientSyncFailed')}</p>
<ul>{clientSync.failures.map(failure => <li key={failure.id}>{failure.id}: {failure.message}</li>)}</ul>
<button type="button" disabled={clientSync.syncing} onClick={retryClient}>{t('clientSyncRetry')}</button>
</div>
)}
{state.status === 'loading' ? <p className={css.status}>{t('loading')}</p> : null}
{state.status === 'error' ? (
<div className={css.failure}>
@@ -1,6 +1,7 @@
/** Read-only Host plugin inventory registered into Web Settings. */
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-modules/client'
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
@@ -26,7 +27,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
export const NS = 'settings.pluginInventory'
/** Services required by the Settings registration and generated Remote face. */
export const inject = ['slots', 'locale', 'remote', 'remote.pluginInventory']
export const inject = ['slots', 'locale', 'remote', 'remote.pluginInventory', 'modules']
/** Contribute the lazy inventory tab to the Plugins settings section. */
export function apply(ctx: ClientContext): void {
@@ -45,7 +46,11 @@ export function apply(ctx: ClientContext): void {
const agentPresetCopy = ctx.locale.bind('settings.agentPreset')
const presetName: PluginInventorySettingsTabInjected['presetName'] = preset =>
presetDisplayText(preset, agentPresetCopy).name
const injected = (): PluginInventorySettingsTabInjected => ({ list, presetName })
const injected = (): PluginInventorySettingsTabInjected => ({
list, presetName,
hooks: { clientSync: ctx.modules.entries.state },
retryClient: () => { void ctx.modules.entries.retry().catch((error: unknown) => { ctx.logger.error(error) }) },
})
ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
name: 'settings.plugins.tab',
@@ -4,6 +4,9 @@
export const zh = {
tab: '插件列表',
loading: '正在读取插件…',
clientSyncing: '正在同步本页面的插件…',
clientSyncFailed: '本页面的插件未能完成同步;服务端的启用状态保持不变。',
clientSyncRetry: '重试本页面同步',
error: '暂时无法读取插件。',
retry: '重试',
search: '搜索插件',
@@ -47,6 +50,9 @@ export type PluginInventoryLocaleKey = keyof typeof zh
export const en = {
tab: 'Plugin list',
loading: 'Reading plugins…',
clientSyncing: 'Syncing plugins on this page…',
clientSyncFailed: 'Some plugins could not sync on this page. Host enablement is unchanged.',
clientSyncRetry: 'Retry this page',
error: 'Plugins are temporarily unavailable.',
retry: 'Retry',
search: 'Search plugins',
@@ -1,4 +1,6 @@
// @vitest-environment jsdom
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { ClientModuleLoader } from '@deepseek-ai/dsh-client-modules/client'
import { Context, Service } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup } from '@testing-library/react'
@@ -21,6 +23,8 @@ type ListResult =
async function bench() {
const ctx = new Context()
const retryClient = vi.fn(async () => {})
ctx.provide('modules', { entries: { state: createSnapshotStore({ syncing: false, failures: [] }), retry: retryClient } } as unknown as ClientModuleLoader)
await ctx.plugin(SlotRegistry).await()
const locale = new LocaleRuntime(ctx)
ctx.provide('locale', locale)
@@ -33,7 +37,7 @@ async function bench() {
const list = vi.fn<() => Promise<ListResult>>()
.mockResolvedValue({ ok: true, value: EMPTY })
ctx.provide('remote.pluginInventory', { list })
return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, list }
return { ctx, retryClient, slots: ctx.get('slots') as SlotRegistry, locale, list }
}
function declare(slots: SlotRegistry): () => void {
@@ -49,7 +53,7 @@ describe('ui-settings-plugin-inventory browser plugin', () => {
})
it('declares only the services used by the Settings Remote contribution', () => {
expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginInventory'])
expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginInventory', 'modules'])
})
it('registers a localized tab without reading the Remote eagerly', async () => {
@@ -65,6 +69,12 @@ describe('ui-settings-plugin-inventory browser plugin', () => {
expect(b.list).not.toHaveBeenCalled()
const injected = (entry.inject as unknown as () => PluginInventorySettingsTabInjected)()
injected.retryClient()
const retryError = vi.spyOn(b.ctx.logger, 'error').mockImplementation(() => {})
b.retryClient.mockRejectedValueOnce(new Error('retry unavailable'))
injected.retryClient()
await vi.waitFor(() => { expect(retryError).toHaveBeenCalled() })
retryError.mockRestore()
await expect(injected.list()).resolves.toEqual(EMPTY)
expect(b.list).toHaveBeenCalledOnce()
b.list.mockResolvedValueOnce({ ok: false, error: { code: 'REMOTE_ERROR', message: 'unavailable' } })
@@ -1,4 +1,7 @@
// @vitest-environment jsdom
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { ClientEntryState } from '@deepseek-ai/dsh-client-modules/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PluginInventorySettingsTab } from '../src/client/PluginInventorySettingsTab.tsx'
@@ -25,6 +28,8 @@ function props(
t,
list,
presetName,
useClientSync: bindSnapshotSelector(createSnapshotStore<ClientEntryState>({ syncing: false, failures: [] })),
retryClient: vi.fn(),
} as PluginInventorySettingsTabProps
}
@@ -378,3 +383,20 @@ describe('PluginInventorySettingsTab', () => {
await act(async () => { deferredFailure.reject(new Error('late failure')) })
})
})
it('shows current-page sync errors and retries without re-reading Host inventory', async () => {
const list = vi.fn(async () => ({ entries: [] }))
const sync = createSnapshotStore<ClientEntryState>({ syncing: true, failures: [] })
const retryClient = vi.fn()
render(<PluginInventorySettingsTab {...props(list)} useClientSync={bindSnapshotSelector(sync)} retryClient={retryClient} />)
expect(screen.getByRole('status').textContent).toContain('Syncing plugins on this page')
await waitFor(() => { expect(list).toHaveBeenCalledOnce() })
act(() => { sync.set({ syncing: false, failures: [{ id: 'client-addon', message: 'download failed' }] }) })
expect(screen.getByText('client-addon: download failed')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Retry this page' }))
expect(retryClient).toHaveBeenCalledOnce()
expect(list).toHaveBeenCalledOnce()
act(() => { sync.set({ syncing: false, failures: [] }) })
expect(screen.queryByRole('alert')).toBeNull()
})
@@ -34,6 +34,12 @@
},
{
"path": "../ui-slots"
},
{
"path": "../modules"
},
{
"path": "../store"
}
]
}
+2 -2
View File
@@ -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 packages/client/web/README.md
README.md: 7bdcec9e2a964b746cbd808497f9222e2b4150fa
README.zh.md: a7243e52a7bababebb75a0ee9f4e22a99fa83337
README.md: 95742491aba7eee868f8e1ce6fede537e2f68384
README.zh.md: 84970c1cffc43f431a7e6563502d056d64c1c952
+2
View File
@@ -67,6 +67,8 @@ The kernel owns exactly three things: the module system, the Cordis Loader, and
The boot page is plain DOM with local CSS whose fallback fonts and colors match the theme tokens that arrive during loading. `internal/status` events drive one spinner node and per-entry labels; hydration preserves the node and animation phase through the application commit, and `fail()` renders the thrown reason. React mounting, slot rendering, and assembly live in `ui-renderer`; `ui-layout` owns the assembled browser-title projection.
The boot kernel delegates manifest entry creation to Client Modules so live graph synchronization owns the same entry identities after startup. The initial activation audit remains strict; later page-local failures appear in Settings → Plugins → Plugin list.
### Source map
| File | Role |
+2
View File
@@ -67,6 +67,8 @@ kind: "package-library"
启动页是原生 DOM 加本地 CSS,其回退字体与颜色匹配加载期间到达的主题 token。`internal/status` 事件驱动一个 spinner 节点与逐 entry 标签;hydrate 会保留该节点与动画相位直到应用提交,`fail()` 渲染抛出的原因。React 挂载、slot 渲染与应用组装位于 `ui-renderer`;`ui-layout` 拥有组装后的浏览器标题投影。
启动内核把清单条目创建交给 Client Modules,使启动后的动态图同步继续持有相同的条目身份。初始激活审计仍然严格;后续页面本地失败显示在「设置 → 插件 → 插件列表」。
### 源码地图
| 文件 | 职责 |
+5 -5
View File
@@ -45,11 +45,11 @@ export async function bootClient(options: ClientBootOptions): Promise<void> {
})
const rows = manifest.plugins.map(row => row.id)
await Promise.all(rows.map(async (name) => {
onEntryState?.(name, 'loading')
const id = await loader.create({ name })
if (loader.resolve(id).fiber === undefined) onEntryState?.(name, 'failed')
}))
for (const name of rows) onEntryState?.(name, 'loading')
await options.modules.entries.start(loader, manifest)
for (const entry of loader.entries()) {
if (entry.fiber === undefined) onEntryState?.(entry.options.name, 'failed')
}
await loader.await()
assertEntriesActive(ctx)
@@ -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 packages/test-support/client-runtime/README.md
README.md: 014bcbf8bc0ed9be596e2a20c07f57c80aaaf7dc
README.zh.md: a14a5281c58a5ea4a8a49e1a826397ae91e5f115
README.md: 819892dfb5b433c6b4dcbe40a445e974137eff71
README.zh.md: ee63f4d930410f7f5069be9027327559249b56ca
@@ -87,7 +87,7 @@ The [generic Remote proxy](../remote-mock/README.md#remote-proxy) supports every
### Roster and startup behavior
`webApp` is the `web` profile's browser roster, read when the assembly entry is first imported from its bundles (`dsh-base`, then `dsh-web-app`) as the launcher composes them, except that a patch matching nothing throws here where the launcher warns: each bundle's `dsh.bundle.patch` list is parsed with the include plugin's YAML dialect and composed by its `applyEntryPatches`, and every enabled row whose package declares `dsh.client.platform === 'web'` becomes a row carrying that declaration's `inject` and `immediately`; `bundleRoster(bundles)` does the same for any bundle list. Nothing is copied from the bundles, so a bundle change is visible at the next test run. `webApp.closure(names)` keeps the named rows plus everything they inject, transitively (the rows a spec needs to boot those plugins as the bundle composes them), `webApp.pick(names)` and `webApp.without(names)` cut it down by hand, all three throw on unknown names, and `ClientRoster.of(rows)` builds one inline. `remoteDefaultResponses` holds default responses for exactly the Remote endpoints the roster calls while booting with no sessions, no workspaces, and default settings; a spec layers its own `RemoteTable` on top with `mock.load(table)`, and any call without a rule fails the test at `dispose()` through `mock.assertNoUnmatched()`. `mount` requires a roster that provides `uiRenderer`; `start` fails loud otherwise instead of returning an empty container. `client.connection` is the roster's Connection service (no `Context` augmentation declares it), and `connectTimeoutMs` bounds the readiness wait, whose failure message lists the mock log. `reload(name)` rebuilds one Loader entry the way client-hmr does (registry-first teardown, then `entry.refresh()`); each client retains its own module system and instance-bound Connection replacement, so starts and reloads can overlap without changing a page global. `unload(name)` removes an entry and waits for plugin cleanup; `flush()` settles React inside `act`. jsdom implements neither `EventSource` (client-hmr opens one at apply) nor `ResizeObserver` (layout components observe size at mount), so `start` reference-counts inert stubs for whichever global is absent and `dispose` removes exactly those — a jsdom gap, not a client identity channel. The `@deepseek-ai/dsh-api-remotes` row is dropped from every roster: its generated Remote clients exist only in built `lib/`, and `remote.<ns>` is what the tier replaces anyway. `start` instead provides one contract-free proxy per `remote.<ns>` service the roster injects (plus the namespaces the mock has rules for at that point; a namespace first registered later has no proxy); `ctx.remote.<ns>.<method>(...args)` becomes a call on the endpoint `<ns>/<method>` carrying the positional args, a stream when the mock registered a `stream()` script and a unary call otherwise, with the generated client's outcome folding (`gateway/internal` for carrier throws, `gateway/cancelled` on abort). An endpoint without a rule is still dispatched, so the mock logs it and `dispose()` fails the test.
`webApp` is the `web` profile's browser roster, read when the assembly entry is first imported from its bundles (`dsh-base`, then `dsh-web-app`) as the launcher composes them, except that a patch matching nothing throws here where the launcher warns: each bundle's `dsh.bundle.patch` list is parsed with the include plugin's YAML dialect and composed by its `applyEntryPatches`, and every enabled row whose package declares `dsh.client.platform === 'web'` becomes a row carrying that declaration's `inject` and `immediately`; `bundleRoster(bundles)` does the same for any bundle list. Nothing is copied from the bundles, so a bundle change is visible at the next test run. `webApp.closure(names)` keeps the named rows plus everything they inject, transitively (the rows a spec needs to boot those plugins as the bundle composes them), `webApp.pick(names)` and `webApp.without(names)` cut it down by hand, all three throw on unknown names, and `ClientRoster.of(rows)` builds one inline. `remoteDefaultResponses` holds default responses for exactly the Remote endpoints the roster calls while booting with no sessions, no workspaces, and default settings; a spec layers its own `RemoteTable` on top with `mock.load(table)`, and any call without a rule fails the test at `dispose()` through `mock.assertNoUnmatched()`. `mount` requires a roster that provides `uiRenderer`; `start` fails loud otherwise instead of returning an empty container. `client.connection` is the roster's Connection service (no `Context` augmentation declares it), and `connectTimeoutMs` bounds the readiness wait, whose failure message lists the mock log. `reload(name)` rebuilds one Loader entry through the shared Client Modules helper (registry-first teardown, then `entry.refresh()`); each client retains its own module system and instance-bound Connection replacement, so starts and reloads can overlap without changing a page global. `unload(name)` removes an entry and waits for plugin cleanup; `flush()` settles React inside `act`. jsdom implements neither `EventSource` (client-hmr opens one at apply) nor `ResizeObserver` (layout components observe size at mount), so `start` reference-counts inert stubs for whichever global is absent and `dispose` removes exactly those — a jsdom gap, not a client identity channel. The `@deepseek-ai/dsh-api-remotes` row is dropped from every roster: its generated Remote clients exist only in built `lib/`, and `remote.<ns>` is what the tier replaces anyway. `start` instead provides one contract-free proxy per `remote.<ns>` service the roster injects (plus the namespaces the mock has rules for at that point; a namespace first registered later has no proxy); `ctx.remote.<ns>.<method>(...args)` becomes a call on the endpoint `<ns>/<method>` carrying the positional args, a stream when the mock registered a `stream()` script and a unary call otherwise, with the generated client's outcome folding (`gateway/internal` for carrier throws, `gateway/cancelled` on abort). An endpoint without a rule is still dispatched, so the mock logs it and `dispose()` fails the test.
### When to use it
@@ -87,7 +87,7 @@ test('registers into the sidebar', async ({ remote, start }) => {
### Roster 与启动行为
`webApp` 是 `web` profile 的浏览器 roster,首次 import 装配入口时从它的 bundle(先 `dsh-base`、再 `dsh-web-app`)按启动器的方式现读,只是匹配不到任何行的补丁在这里抛错、启动器只警告:每个 bundle 的 `dsh.bundle.patch` 列表用 include 插件的 YAML 方言解析、用它的 `applyEntryPatches` 合成,每个未禁用且其包声明 `dsh.client.platform === 'web'` 的行成为一行,带上该声明的 `inject` 与 `immediately`;`bundleRoster(bundles)` 对任意 bundle 列表做同样的事。没有任何东西从 bundle 拷贝出来,bundle 一改下次跑测试就能看见。`webApp.closure(names)` 保留点名的行及其传递注入的全部行(即按 bundle 组合方式起这些插件所需的行),`webApp.pick(names)` 与 `webApp.without(names)` 手工裁剪,三者都对未知名字抛错,`ClientRoster.of(rows)` 内联构造一份。`remoteDefaultResponses` 是 roster 在没有 session、没有 workspace、默认设置下启动时恰好会打的那些 Remote 端点的默认响应;测试用 `mock.load(table)` 在其上叠加自己的 `RemoteTable`,任何没有规则的调用都会在 `dispose()` 时经 `mock.assertNoUnmatched()` 让测试失败。`mount` 要求 roster 提供 `uiRenderer`;否则 `start` 响亮失败而不是返回一个空容器。`client.connection` 是 roster 的 Connection 服务(没有任何 `Context` 增强声明它),`connectTimeoutMs` 限定等就绪的时长,超时消息列出 mock log。`reload(name)` 按 client-hmr 的方式重建一个 Loader entry(先拆 registry,再 `entry.refresh()`);每个客户端都保留自己的模块系统和绑定实例的 Connection 替换,因此启动与重载可以重叠而无需改写页面全局变量。`unload(name)` 移除 entry 并等待插件清理完成,`flush()` 在 `act` 内让 React 落定。jsdom 既没有 `EventSource`(client-hmr 在 apply 时打开一个)也没有 `ResizeObserver`(布局组件挂载时观察尺寸),所以 `start` 对缺失的全局惰性桩做引用计数,`dispose` 只移除它装的那些——这是 jsdom 的缺口,不是客户端身份通道。每个 roster 里的 `@deepseek-ai/dsh-api-remotes` 行都会被去掉:它生成的 Remote 客户端只存在于构建后的 `lib/`,而 `remote.<ns>` 正是本档要替掉的东西。`start` 改为给 roster 注入的每个 `remote.<ns>` 服务(加上此刻 mock 登记过规则的命名空间;之后才首次登记的命名空间没有代理)提供一个无契约代理;`ctx.remote.<ns>.<method>(...args)` 变成对端点 `<ns>/<method>` 的调用,携带位置参数,mock 登记了 `stream()` 脚本的走流、否则走一元,并沿用生成客户端的结果折叠(载体抛错折成 `gateway/internal`,中止折成 `gateway/cancelled`)。没有规则的端点照样发出,所以 mock 会记下它、`dispose()` 让测试失败。
`webApp` 是 `web` profile 的浏览器 roster,首次 import 装配入口时从它的 bundle(先 `dsh-base`、再 `dsh-web-app`)按启动器的方式现读,只是匹配不到任何行的补丁在这里抛错、启动器只警告:每个 bundle 的 `dsh.bundle.patch` 列表用 include 插件的 YAML 方言解析、用它的 `applyEntryPatches` 合成,每个未禁用且其包声明 `dsh.client.platform === 'web'` 的行成为一行,带上该声明的 `inject` 与 `immediately`;`bundleRoster(bundles)` 对任意 bundle 列表做同样的事。没有任何东西从 bundle 拷贝出来,bundle 一改下次跑测试就能看见。`webApp.closure(names)` 保留点名的行及其传递注入的全部行(即按 bundle 组合方式起这些插件所需的行),`webApp.pick(names)` 与 `webApp.without(names)` 手工裁剪,三者都对未知名字抛错,`ClientRoster.of(rows)` 内联构造一份。`remoteDefaultResponses` 是 roster 在没有 session、没有 workspace、默认设置下启动时恰好会打的那些 Remote 端点的默认响应;测试用 `mock.load(table)` 在其上叠加自己的 `RemoteTable`,任何没有规则的调用都会在 `dispose()` 时经 `mock.assertNoUnmatched()` 让测试失败。`mount` 要求 roster 提供 `uiRenderer`;否则 `start` 响亮失败而不是返回一个空容器。`client.connection` 是 roster 的 Connection 服务(没有任何 `Context` 增强声明它),`connectTimeoutMs` 限定等就绪的时长,超时消息列出 mock log。`reload(name)` 通过共享的 Client Modules 辅助函数重建一个 Loader entry(先拆 registry,再 `entry.refresh()`);每个客户端都保留自己的模块系统和绑定实例的 Connection 替换,因此启动与重载可以重叠而无需改写页面全局变量。`unload(name)` 移除 entry 并等待插件清理完成,`flush()` 在 `act` 内让 React 落定。jsdom 既没有 `EventSource`(client-hmr 在 apply 时打开一个)也没有 `ResizeObserver`(布局组件挂载时观察尺寸),所以 `start` 对缺失的全局惰性桩做引用计数,`dispose` 只移除它装的那些——这是 jsdom 的缺口,不是客户端身份通道。每个 roster 里的 `@deepseek-ai/dsh-api-remotes` 行都会被去掉:它生成的 Remote 客户端只存在于构建后的 `lib/`,而 `remote.<ns>` 正是本档要替掉的东西。`start` 改为给 roster 注入的每个 `remote.<ns>` 服务(加上此刻 mock 登记过规则的命名空间;之后才首次登记的命名空间没有代理)提供一个无契约代理;`ctx.remote.<ns>.<method>(...args)` 变成对端点 `<ns>/<method>` 的调用,携带位置参数,mock 登记了 `stream()` 脚本的走流、否则走一元,并沿用生成客户端的结果折叠(载体抛错折成 `gateway/internal`,中止折成 `gateway/cancelled`)。没有规则的端点照样发出,所以 mock 会记下它、`dispose()` 让测试失败。
### 何时使用
@@ -6,7 +6,7 @@
*/
import { Context, type Plugin } from '@deepseek-ai/cordis'
import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
import { tearDownEntryFiber } from '@deepseek-ai/dsh-client-hmr/client'
import { tearDownEntryFiber } from '@deepseek-ai/dsh-client-modules/client'
import {
installConnection,
type ConnectionHandle,
@@ -262,7 +262,7 @@ export class TestClient {
}
/**
* Rebuild one Loader entry: client-hmr's registry-first fiber teardown, then
* Rebuild one Loader entry: Client Modules' registry-first fiber teardown, then
* `entry.refresh()`. Each client's module table retains its own instance-bound
* Connection plugin, so reloads do not coordinate through process globals.
* Requires a live client: after `dispose()` the Loader holds no entries and
+9
View File
@@ -2303,6 +2303,9 @@ importers:
'@deepseek-ai/cordis-plugin-loader':
specifier: workspace:^
version: link:../../../vendor/loader
'@deepseek-ai/dsh-client-store':
specifier: workspace:^
version: link:../store
'@deepseek-ai/dsh-host-webserver':
specifier: workspace:^
version: link:../../host/webserver
@@ -3843,6 +3846,12 @@ 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-store':
specifier: workspace:^
version: link:../store
'@deepseek-ai/dsh-client-test-runtime':
specifier: workspace:^
version: link:../../test-support/client-runtime
+1
View File
@@ -38,6 +38,7 @@
"apps/web/tests/details-session-lifecycle.e2e.ts",
"apps/web/tests/document-preview.e2e.ts",
"apps/web/tests/plugin-config.e2e.ts",
"apps/web/tests/client-plugin-live.e2e.ts",
"apps/web/tests/settings-chrome.e2e.ts",
"apps/web/tests/models-settings.e2e.ts",
"apps/web/tests/deepseek-messages-settings.e2e.ts",
-2
View File
@@ -284,8 +284,6 @@ export default defineConfig({
'packages/experimental/inspector/src/shared/bridge/messages/runtime/{command-codec,console-frames,frames,value-codec}.ts',
'packages/experimental/inspector/src/shared/bridge/messages/sources/{codec,frames}.ts',
'packages/experimental/inspector/src/worker/inspection/{cordis-store,query-router,realm-store}.ts',
'packages/client/modules/src/client/system.ts',
'packages/client/hmr/src/client/index.ts',
// Web config-tree boot round: the new host-side web-transport halves
// whose remaining branches need real-composition/process harnesses.
// TODO(gui): cover and remove with the client test lane above.