Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

This commit is contained in:
creatixchu
2026-07-31 10:47:25 +08:00
25 changed files with 441 additions and 78 deletions

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: efba9e2eb0b148677fc7ac18bfad6333fb6f80da
README.zh.md: b057bfdd8c0a269252496d0c6a0fc4184932fd72
README.md: 99565b349d782c58752ac3e73ce7c0be527f78a8
README.zh.md: a8ed0a4949ccefce53933b4f2fb8f51f5291684f

View File

@@ -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.

View File

@@ -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 属于浏览器侧内核机制;这里没有任何内容进入模型请求。

View File

@@ -58,6 +58,47 @@ interface PkgMeta {
immediately: boolean
}
/** Recovery instruction shared by grouped startup and steady-state bundle diagnostics. */
const CLIENT_BUNDLE_BUILD_INSTRUCTION = 'run `pnpm run build` before launch'
/** 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: client bundle not found; ${CLIENT_BUNDLE_BUILD_INSTRUCTION}:`,
` package: ${packageName}`,
` path: ${clientPath}`,
].join('\n'),
{ 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 bundles not found; ${CLIENT_BUNDLE_BUILD_INSTRUCTION}:`)
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 +179,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 +235,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 +360,22 @@ export class ClientModuleHostService extends Service {
return meta
}
/**
* Read the activation-time bundle revision.
* @param pkgName - package that declares the client bundle.
* @param clientPath - absolute path of the built client artifact.
* @returns the bundle content's short hash for use as its revision.
* @throws {MissingClientBundleError} when the read fails with `ENOENT`; other filesystem errors are rethrown unchanged.
*/
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 +391,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
}

View File

@@ -0,0 +1,87 @@
/** Node-half composition diagnostics for package metadata and built client bundles. */
import { mkdirSync, mkdtempSync, realpathSync, 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 ??= realpathSync(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<HttpServerService, 'port' | 'register' | 'tapIndex'> = {
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 bundles not found; run `pnpm run build` before 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('client-modules: 1 client package failed to compose:')
expect(String(thrown)).toContain(' other failures:')
expect(String(thrown)).toContain('EISDIR')
expect(String(thrown)).not.toContain('pnpm run build')
})
})