From 4517aa349ce34821b70ab35f1fdcdbc4d2bb6863 Mon Sep 17 00:00:00 2001 From: imccyu Date: Thu, 30 Jul 2026 16:52:54 +0800 Subject: [PATCH] fix: client module loader report package resolve info --- ...7-23-client-plugin-loading-model.i18n.yaml | 4 +- .../2026-07-23-client-plugin-loading-model.md | 4 +- ...26-07-23-client-plugin-loading-model.zh.md | 4 +- packages/client/modules/README.i18n.yaml | 4 +- packages/client/modules/README.md | 2 + packages/client/modules/README.zh.md | 2 + packages/client/modules/src/index.ts | 53 ++++++++++-- .../client/modules/tests/node-half.spec.ts | 85 +++++++++++++++++++ 8 files changed, 144 insertions(+), 14 deletions(-) create mode 100644 packages/client/modules/tests/node-half.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml index f140786632..451dcd1fca 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md -2026-07-23-client-plugin-loading-model.md: b0873b7aa7bccd3d613f3113fa18207770952e5d -2026-07-23-client-plugin-loading-model.zh.md: f3472dbfc5a78924e77337bf92ce5983c8492c4c +2026-07-23-client-plugin-loading-model.md: 02347f2964942b89ec1f0a6ec483f4c2b2f9e68c +2026-07-23-client-plugin-loading-model.zh.md: ea927d35860fbbba567c47cea0ee3a45133ce0f4 diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md index b0873b7aa7..02347f2964 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md @@ -56,8 +56,8 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the **Host side — compose the graph.** -1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, and `--dev` appends the `client-hmr` row in code (`AppCLIEntry`) before the settle/sweep so the fail-loud triple covers it. A roster row that fails to import is caught by the boot's `assertEntriesLoaded`. -2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dshClient` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`. The `inject` edges and the `immediately` mark come from manifests, never hand-copied. It refuses a declared plugin without a built `./client` bundle, and any malformed declaration field — activation-time fail loud (a FAILED fiber the sweep reports). +1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, and `--dev` appends the `client-hmr` row in code (`AppCLIEntry`) before the host activation audit so the same check covers it. A roster row that fails to import is caught by `assertEntriesLoaded`; a row whose fiber rejects is reported with its original stack by `assertEntriesActivated` ([host boot decision](2026-07-24-web-config-tree-boot-and-transport-layering.md)). +2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dshClient` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`. The `inject` edges and the `immediately` mark come from manifests, never hand-copied. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the host audit reports either error from the FAILED fiber. 3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per name forever and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Each bundle's content hash is its `rev` (cache busting + HMR diff anchor), the row set hashes into `graph.rev`, and every row is fetch-served: `/plugins//client.js?rev=…`. The graph types are single-sourced in the modules package's `./impl` export — the webserver knows nothing about the graph (it is a plain route-registration plugin; modules registers the bundle route and taps the index render itself). Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a dshClient package existing in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted. diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md index f3472dbfc5..ea927d3586 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md @@ -56,8 +56,8 @@ vendored Loader 经其 `internal` seam 消费模块系统——唯一调用点 **host 侧——组合这张图。** -1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,`--dev` 由代码(`AppCLIEntry`)在 settle/sweep 之前追加 `client-hmr` 行,使 fail-loud 三件套一并覆盖它。名册行 import 失败由 boot 的 `assertEntriesLoaded` 捕获。 -2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dshClient` 声明,组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`。`inject` 边与 `immediately` 标记都来自 manifest,永不人肉抄写。它拒绝声明了插件却没有已构建 `./client` bundle 的包,也拒绝任何畸形的声明字段——激活期大声失败(FAILED fiber,由 sweep 上报)。 +1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,`--dev` 由代码(`AppCLIEntry`)在 host 激活检查之前追加 `client-hmr` 行,使同一项检查覆盖它。名册行 import 失败由 `assertEntriesLoaded` 捕获;fiber reject 的行则由 `assertEntriesActivated` 报告原始 stack([host boot 决策](2026-07-24-web-config-tree-boot-and-transport-layering.md))。 +2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dshClient` 声明,组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`。`inject` 边与 `immediately` 标记都来自 manifest,永不人肉抄写。它会拒绝没有已构建 `./client` bundle 的已声明插件,并把它们的 package/path 行归到一条源码构建要求下;畸形声明字段同样会让激活失败,host 检查会从 FAILED fiber 报告这两类错误。 3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按名永久缓存,bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。每个 bundle 的内容哈希是其 `rev`(缓存失效 + HMR diff 锚点),行集合哈希进 `graph.rev`,每一行都经 fetch 供给:`/plugins//client.js?rev=…`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知(它是朴素路由注册插件;bundle 路由和 index 渲染 tap 都由 modules 自己注册)。 为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个 dshClient 包存在于仓库里,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。 diff --git a/packages/client/modules/README.i18n.yaml b/packages/client/modules/README.i18n.yaml index 80bf46a996..c3dfc36e65 100644 --- a/packages/client/modules/README.i18n.yaml +++ b/packages/client/modules/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/modules/README.md -README.md: efba9e2eb0b148677fc7ac18bfad6333fb6f80da -README.zh.md: b057bfdd8c0a269252496d0c6a0fc4184932fd72 +README.md: 99565b349d782c58752ac3e73ce7c0be527f78a8 +README.zh.md: a8ed0a4949ccefce53933b4f2fb8f51f5291684f diff --git a/packages/client/modules/README.md b/packages/client/modules/README.md index efba9e2eb0..99565b349d 100644 --- a/packages/client/modules/README.md +++ b/packages/client/modules/README.md @@ -8,6 +8,8 @@ Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`wi Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → surface; shell-own static registry (`registerStatic`, app-shell) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → fetch + execute + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the fetch branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (fetch + execute, registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and the materialized record so the next prefetch/import refetches (the HMR hook). +The Node half scans enabled Loader entries for web `dshClient` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, and serves it under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures. + ## Model Experience None, as the module loader is browser-side kernel machinery; nothing here reaches a model request. diff --git a/packages/client/modules/README.zh.md b/packages/client/modules/README.zh.md index b057bfdd8c..a8ed0a4949 100644 --- a/packages/client/modules/README.zh.md +++ b/packages/client/modules/README.zh.md @@ -8,6 +8,8 @@ 解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`,app-shell)→ 模块;已注册 factory → 物化;模块图记录(`window.__DSH_BOOT__`)→ 抓取 + 执行 + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含抓取分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段加载钩子(抓取 + 执行,只注册;并发调用共享一个进行中的任务);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新抓取;它是 HMR(热模块替换)钩子。 +Node 侧会扫描已启用的 Loader 配置项以发现 web `dshClient` 包,解析每个 `exports["./client"]`,把构建后的组合包哈希写入启动图,并通过 `/plugins` 提供该文件。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费客户端导出的构建产物;缺失文件共享一条构建要求,随后以 package/path list 列出各项,而无关的文件系统错误仍是独立故障。 + ## 模型体验 无。模块 loader 属于浏览器侧内核机制;这里没有任何内容进入模型请求。 diff --git a/packages/client/modules/src/index.ts b/packages/client/modules/src/index.ts index ecfc31b77f..f7488ff2f4 100644 --- a/packages/client/modules/src/index.ts +++ b/packages/client/modules/src/index.ts @@ -58,6 +58,40 @@ interface PkgMeta { immediately: boolean } +/** Missing built client export, retained as structured data for activation-error grouping. */ +class MissingClientBundleError extends Error { + constructor( + readonly packageName: string, + readonly clientPath: string, + cause: unknown, + ) { + super( + `client-modules: ${packageName} needs to be built before source launch; client bundle not found at ${clientPath}`, + { cause }, + ) + } +} + +/** Activation failures grouped by actionable package-build errors and unrelated failures. */ +class ClientPackageCompositionError extends AggregateError { + constructor(failures: Error[]) { + const missingBundles = failures.filter((error): error is MissingClientBundleError => error instanceof MissingClientBundleError) + const otherFailures = failures.filter(error => !(error instanceof MissingClientBundleError)) + const packageNoun = failures.length === 1 ? 'package' : 'packages' + const lines = [`client-modules: ${String(failures.length)} client ${packageNoun} failed to compose:`] + if (missingBundles.length > 0) { + lines.push(' client packages requiring a build before source launch:') + for (const error of missingBundles) { + lines.push(` - package: ${error.packageName}`, ` path: ${error.clientPath}`) + } + } + if (otherFailures.length > 0) { + lines.push(' other failures:', ...otherFailures.map(error => ` - ${error.message}`)) + } + super(failures, lines.join('\n')) + } +} + /** One composed table row: the wire entry plus its bundle path. */ interface WebPluginRecord { entry: WebBootEntry @@ -138,7 +172,7 @@ export function injectBootManifest(html: string, graph: WebBootGraph): string { * + bundle route + index tap. Construction runs the activation scan * synchronously — a malformed declaration or missing bundle among the * already-loaded entries aggregates into one loud throw (FAILED fiber; the - * boot sweep reports it). + * boot activation audit reports it). */ export class ClientModuleHostService extends Service { static inject = ['httpServer', 'loader'] @@ -194,10 +228,7 @@ export class ClientModuleHostService extends Service { const failures: Error[] = [] this.flush(err => failures.push(err)) if (failures.length > 0) { - throw new AggregateError( - failures, - `client-modules: ${String(failures.length)} client package(s) failed to compose:\n${failures.map(e => ` - ${e.message}`).join('\n')}`, - ) + throw new ClientPackageCompositionError(failures) } ctx.effect( @@ -322,6 +353,16 @@ export class ClientModuleHostService extends Service { return meta } + /** Read the activation-time bundle revision, translating only a missing build artifact into source-launch guidance. */ + private initialBundleRevision(pkgName: string, clientPath: string): string { + try { + return shortHash(readFileSync(clientPath)) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + throw new MissingClientBundleError(pkgName, clientPath, error) + } + } + /** Reconcile one entry name against the live loader entries. @returns whether the table changed. */ private processOne(entryName: string): boolean { let qualifies = false @@ -337,7 +378,7 @@ export class ClientModuleHostService extends Service { if (meta === null) return false // The rev rides the row from here on: a fiber restart reuses the row (and // its rev) untouched; only rebuilt() re-reads the bundle. - const rev = shortHash(readFileSync(meta.clientPath)) + const rev = this.initialBundleRevision(entryName, meta.clientPath) this.table.set(entryName, { entry: graphRow(entryName, rev, meta.inject, meta.immediately), clientPath: meta.clientPath }) return true } diff --git a/packages/client/modules/tests/node-half.spec.ts b/packages/client/modules/tests/node-half.spec.ts new file mode 100644 index 0000000000..37c41d9742 --- /dev/null +++ b/packages/client/modules/tests/node-half.spec.ts @@ -0,0 +1,85 @@ +/** Node-half composition diagnostics for package metadata and built client bundles. */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context } from 'cordis' +import { afterEach, describe, expect, it } from 'vitest' +import type { HttpServerService } from '@deepseek-ai/dsh-host-webserver' +import { ClientModuleHostService } from '../src/index.ts' + +let root: string | undefined + +afterEach(() => { + if (root !== undefined) rmSync(root, { recursive: true, force: true }) + root = undefined +}) + +/** Create a resolvable dshClient package whose client export points at the returned path. */ +function writePackage(packageName: string): string { + root ??= mkdtempSync(join(tmpdir(), 'dsh-client-modules-')) + const pkgRoot = join(root, 'node_modules', ...packageName.split('/')) + const clientPath = join(pkgRoot, 'lib', 'client.js') + mkdirSync(pkgRoot, { recursive: true }) + writeFileSync(join(pkgRoot, 'package.json'), JSON.stringify({ + name: packageName, + exports: { + './client': './lib/client.js', + './package.json': './package.json', + }, + dshClient: { platform: 'web' }, + })) + return clientPath +} + +/** Construct the node-half service over the enabled fixture entries. */ +function construct(packageNames: string[]): ClientModuleHostService { + const ctx = new Context() + ctx.baseUrl = pathToFileURL(root!).href + '/' + ctx.provide('loader', { + *entries() { + for (const packageName of packageNames) { + yield { options: { name: packageName }, fiber: {}, disabled: false } + } + }, + }) + const httpServer: Pick = { + port: 0, + register: () => () => {}, + tapIndex: () => () => {}, + } + ctx.provide('httpServer', httpServer as HttpServerService) + return new ClientModuleHostService(ctx) +} + +describe('client bundle activation', () => { + it('groups missing bundles under one source-build instruction with a package/path list', () => { + const firstName = '@fixture/missing-first' + const secondName = '@fixture/missing-second' + const firstPath = writePackage(firstName) + const secondPath = writePackage(secondName) + expect(() => construct([firstName, secondName])).toThrow([ + 'client-modules: 2 client packages failed to compose:', + ' client packages requiring a build before source launch:', + ` - package: ${firstName}`, + ` path: ${firstPath}`, + ` - package: ${secondName}`, + ` path: ${secondPath}`, + ].join('\n')) + }) + + it('does not report other bundle read failures as missing builds', () => { + const packageName = '@fixture/unreadable-client' + const clientPath = writePackage(packageName) + mkdirSync(clientPath, { recursive: true }) + let thrown: unknown + try { + construct([packageName]) + } catch (error) { + thrown = error + } + expect(String(thrown)).toContain('EISDIR') + expect(String(thrown)).not.toContain('requiring a build before source launch') + }) +})