Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 属于浏览器侧内核机制;这里没有任何内容进入模型请求。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
87
packages/client/modules/tests/node-half.spec.ts
Normal file
87
packages/client/modules/tests/node-half.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user