mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
fix(inspector): stabilize client bootstrap identity
This commit is contained in:
+2
-2
@@ -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: 0fe4e86410f3b313ec5a31099d5a6ed1f828585b
|
||||
2026-07-23-client-plugin-loading-model.zh.md: 386b0edb722d8cedd9325c941f9b392b8cdc8ae2
|
||||
2026-07-23-client-plugin-loading-model.md: d1e1938122e059b6db38b7f37c064820134838ea
|
||||
2026-07-23-client-plugin-loading-model.zh.md: 6efbfe7c1a7f72d1f724e479fb56601346533a36
|
||||
|
||||
@@ -53,8 +53,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, including the always-mounted `client-hmr` row. 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 `dsh.client` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`. The row's three optional fields come from manifests, never hand-copied. Composition orders requested dynamic rows before their consumers, rejects synchronous request cycles, and assigns every row to exactly one initial batch. 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. Initial rows receive an opaque process nonce plus sequence without hashing their artifacts; startup combo revisions hash the combined script inputs plus indexed map, and the rows plus batch descriptors hash into `graph.rev`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph, while modules registers the combo route and contributes structured index-injection rows.
|
||||
2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) resolves each live Loader entry through the same `name` and owning-tree `baseUrl` inputs that imported its Host face, then reads the nearest owning package.json `dsh.client` declaration and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`. The manifest package name is the browser module identity even when an overlay names a relative source or built entry file. The row's three optional fields come from manifests, never hand-copied. Composition orders requested dynamic rows before their consumers, rejects synchronous request cycles, and assigns every row to exactly one initial batch. 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 entry name and owning-tree base URL for the process lifetime 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. Initial rows receive an opaque process nonce plus sequence without hashing their artifacts; startup combo revisions hash the combined script inputs plus indexed map, and the rows plus batch descriptors hash into `graph.rev`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph, while modules registers the combo route and contributes structured index-injection rows.
|
||||
|
||||
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 package declaring `dsh.client` 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.
|
||||
|
||||
|
||||
@@ -53,8 +53,8 @@ Host 会快照每个已构建插件产物,并把每个调度阶段的有序 ro
|
||||
**host 侧——组合这张图。**
|
||||
|
||||
1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,包括无条件挂载的 `client-hmr` 行。名册行 import 失败由 `assertEntriesLoaded` 捕获;fiber reject 的行则由 `assertEntriesActivated` 报告原始 stack([host boot 决策](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md))。
|
||||
2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dsh.client` 声明,组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`。Row 的三个可选字段都来自 manifest,永不人肉抄写。组合会把被请求的动态图 row 排到消费者之前、拒绝同步请求环,并把每个 row 恰好分配给一个初始批次。它会拒绝没有已构建 `./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,初扫与稳态共享一条实现。初始 row 使用不透明的进程 nonce 加序号,不对其产物求哈希;启动 combo revision 对合并脚本输入及 indexed map 求哈希,row 与批次描述再共同哈希进 `graph.rev`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知;modules 会注册 combo 路由并贡献结构化 index 注入行。
|
||||
2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)使用 Host face import 时相同的 `name` 与所属 tree `baseUrl` 解析每个 live Loader entry,再读取最近归属 package.json 的 `dsh.client` 声明并组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`。即使 overlay 指向相对的 source 或 built entry 文件,manifest 包名仍是浏览器模块身份。Row 的三个可选字段都来自 manifest,永不人肉抄写。组合会把被请求的动态图 row 排到消费者之前、拒绝同步请求环,并把每个 row 恰好分配给一个初始批次。它会拒绝没有已构建 `./client` bundle 的已声明插件,并把它们的 package/path 行归到一条源码构建要求下;畸形声明字段同样会让激活失败,Host 检查会从 FAILED fiber 报告这两类错误。
|
||||
3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按 entry 名与所属 tree base URL 缓存至进程结束,bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。初始 row 使用不透明的进程 nonce 加序号,不对其产物求哈希;启动 combo revision 对合并脚本输入及 indexed map 求哈希,row 与批次描述再共同哈希进 `graph.rev`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知;modules 会注册 combo 路由并贡献结构化 index 注入行。
|
||||
|
||||
为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个在仓库中声明了 dsh.client 的包,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。
|
||||
|
||||
|
||||
+2
-2
@@ -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-08-24-cordis-runtime-tree-inspection.md
|
||||
2026-08-24-cordis-runtime-tree-inspection.md: 9784018a050bdb27e791a44e1cd342a31cec42c0
|
||||
2026-08-24-cordis-runtime-tree-inspection.zh.md: 05b9b0d4387447b7c8df05b7c7e771e96c767bd9
|
||||
2026-08-24-cordis-runtime-tree-inspection.md: 52a59a8f930fc357426dc96cc3e3844781f39d13
|
||||
2026-08-24-cordis-runtime-tree-inspection.zh.md: a6585a6a8be78e237f59f69ab96b9b4248650aa0
|
||||
|
||||
@@ -42,7 +42,7 @@ The identities are intentionally distinct:
|
||||
- `NodeId` is assigned per DevTools connection when a node enters that frontend's document. It remains stable while the corresponding backend node is retained and is discarded when that node leaves the tree, on the rare full-document fallback, or when the connection closes.
|
||||
- `RemoteObjectId` is assigned by the selected Runtime session when `DOM.resolveNode` exposes the live object. It remains scoped to that DevTools connection and object group.
|
||||
|
||||
`sourceId` identifies one Client runtime instance and remains stable across its automatic transport reconnects; `generation` identifies one WebSocket admission. Disconnect removes the synthetic context from the Console with `Runtime.executionContextDestroyed`. Reconnection announces a fresh CDP execution-context id because the destroyed id and its RemoteObjects cannot be reused, but this does not imply that the browser's underlying JavaScript realm was recreated.
|
||||
`sourceId` identifies one browser-tab Client runtime and is retained in that tab's `sessionStorage`, so automatic transport reconnects and page refreshes reuse it; `generation` identifies one WebSocket admission and always rotates. Disconnect removes the synthetic context from the Console with `Runtime.executionContextDestroyed`. Reconnection announces a fresh CDP execution-context id because the destroyed id and its RemoteObjects cannot be reused, but this does not imply that the browser's underlying JavaScript realm was recreated.
|
||||
|
||||
Standard CDP does not place a `RemoteObjectId` field on `DOM.Node`. `DOM.Node` carries `nodeId` and `backendNodeId`; `DOM.resolveNode` returns the corresponding `Runtime.RemoteObject`, and `DOM.requestNode` performs the reverse mapping. The implementation keeps these three CDP identities correlated without adding non-standard DOM fields.
|
||||
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ collector 从 root、注册表中的每个 live Fiber,以及每个 event hook
|
||||
- `NodeId` 在节点进入某个 frontend document 时按 DevTools 连接分配;对应 backend node 被保留期间保持稳定,并在节点离开树、少见的整 document fallback 或连接关闭时丢弃。
|
||||
- `RemoteObjectId` 在 `DOM.resolveNode` 暴露实时对象时由选定的 Runtime session 分配;它只属于该 DevTools 连接和 object group。
|
||||
|
||||
`sourceId` 标识一个 Client runtime instance,并在自动重连 transport 时保持稳定;`generation` 标识一次 WebSocket 接纳。断联通过 `Runtime.executionContextDestroyed` 从 Console 移除 synthetic context。重连会发布新的 CDP execution-context id,因为已销毁的 id 及其 RemoteObject 不能复用;这并不表示浏览器底层 JavaScript realm 被重新创建。
|
||||
`sourceId` 标识一个浏览器 tab 的 Client runtime,并保存在该 tab 的 `sessionStorage` 中,因此自动重连 transport 与页面刷新都会复用它;`generation` 标识一次 WebSocket 接纳且每次都会轮换。断联通过 `Runtime.executionContextDestroyed` 从 Console 移除 synthetic context。重连会发布新的 CDP execution-context id,因为已销毁的 id 及其 RemoteObject 不能复用;这并不表示浏览器底层 JavaScript realm 被重新创建。
|
||||
|
||||
标准 CDP 不会在 `DOM.Node` 上放置 `RemoteObjectId` 字段。`DOM.Node` 携带 `nodeId` 与 `backendNodeId`;`DOM.resolveNode` 返回对应的 `Runtime.RemoteObject`,`DOM.requestNode` 执行反向映射。实现会关联这三类 CDP 身份,而不添加非标准 DOM 字段。
|
||||
|
||||
|
||||
+1
-1
@@ -147,7 +147,7 @@
|
||||
"release:publish": "tsx scripts/release/publish.ts",
|
||||
"dsh": "node --import tsx/esm apps/cli/src/bin.ts",
|
||||
"demo:code-mode": "node scripts/demo-code-mode.mjs",
|
||||
"demo:inspector": "node --import tsx/esm apps/cli/src/bin.ts web --patch ./packages/experimental/inspector/cordis.patch.yml",
|
||||
"demo:inspector": "node --import tsx/esm apps/cli/src/bin.ts web --patch ./packages/experimental/inspector/cordis.source.patch.yml",
|
||||
"mock:llm": "node --import tsx packages/test-support/llm-mock-server/src/bin.ts",
|
||||
"dev:web": "tsx scripts/dev-web.ts --poll",
|
||||
"postinstall": "node scripts/install-lefthook.mjs"
|
||||
|
||||
@@ -23,13 +23,14 @@
|
||||
*/
|
||||
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import { readFileSync, statSync } from 'node:fs'
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import { createRequire } from 'node:module'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { dirname, isAbsolute, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/cordis-plugin-loader'
|
||||
import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { optionalStringArray, stripClientSuffix } from './client/manifest.ts'
|
||||
import type { WebBootBatch, WebBootBatchPhase, WebBootEntry, WebBootGraph } from './client/manifest.ts'
|
||||
@@ -85,6 +86,11 @@ interface PkgMeta extends WebBootRowFields {
|
||||
clientPath: string
|
||||
}
|
||||
|
||||
interface ResolvedPkgMeta {
|
||||
packageName: string
|
||||
meta: PkgMeta
|
||||
}
|
||||
|
||||
/** Recovery instruction shared by grouped startup and steady-state bundle diagnostics. */
|
||||
const CLIENT_BUNDLE_BUILD_INSTRUCTION = 'run `pnpm run build` before launch'
|
||||
|
||||
@@ -129,6 +135,10 @@ class ClientPackageCompositionError extends AggregateError {
|
||||
/** One composed table row: the wire entry plus the resolved package metadata behind it. */
|
||||
interface WebPluginRecord {
|
||||
entry: WebBootEntry
|
||||
/** Loader specifier whose active row contributes this browser module. */
|
||||
loaderName: string
|
||||
/** Loader resolution input that selected this package instance. */
|
||||
sourceKey: string
|
||||
meta: PkgMeta
|
||||
/** Exact build artifact included in the startup batches. */
|
||||
bundle: Buffer
|
||||
@@ -167,6 +177,15 @@ const SOURCE_MAP_TRAILER = /(?:\r?\n)?\/\/# sourceMappingURL=[^\r\n]*(?:\r?\n)?$
|
||||
/** Debugger source name appended to page bundles in the WebWorker image. */
|
||||
const SOURCE_URL_TRAILER = /(?:\r?\n)?\/\/# sourceURL=([^\r\n]+)(?:\r?\n)?$/
|
||||
|
||||
/** Return a bare package-root specifier, excluding package subpaths and path-like entries. */
|
||||
function exactPackageSpecifier(specifier: string): string | undefined {
|
||||
if (specifier.startsWith('@')) {
|
||||
const parts = specifier.split('/')
|
||||
return parts.length === 2 && parts.every(Boolean) ? specifier : undefined
|
||||
}
|
||||
return specifier.length > 0 && !specifier.includes('/') ? specifier : undefined
|
||||
}
|
||||
|
||||
/** Narrow an unknown parsed JSON value to the `dsh.client` declaration, throwing on malformed fields. */
|
||||
function parseDshClient(pkgName: string, value: unknown): DshClientDeclaration | undefined {
|
||||
if (value === undefined) return undefined
|
||||
@@ -504,14 +523,12 @@ export class ClientModuleRegistry extends Service {
|
||||
static inject = ['webServer', 'loader']
|
||||
|
||||
private readonly table = new Map<string, WebPluginRecord>()
|
||||
// Negative verdicts (unresolvable specifier — builtins like cordis:include,
|
||||
// subpath rows — or a package without a web `dsh.client` declaration) are
|
||||
// cached as null and never expire: plugin-set changes take effect on restart.
|
||||
private readonly pkgMeta = new Map<string, PkgMeta | null>()
|
||||
// Resolution is entry-local: the same specifier can resolve differently in
|
||||
// separate config trees. Negative verdicts remain stable until restart.
|
||||
private readonly pkgMeta = new Map<string, ResolvedPkgMeta | null>()
|
||||
private readonly rebuildListeners = new Set<(id: string, rev: string) => void>()
|
||||
private readonly graphListeners = new Set<() => void>()
|
||||
private readonly dirty = new Set<string>()
|
||||
private readonly resolvePkgJson: (spec: string) => string
|
||||
private readonly initialRevisionNonce = randomBytes(8).toString('hex')
|
||||
private nextInitialRevision = 0
|
||||
private responses = new Map<string, { body: Buffer; contentType: string }>()
|
||||
@@ -527,16 +544,6 @@ export class ClientModuleRegistry extends Service {
|
||||
*/
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'clientModules')
|
||||
// Resolution anchor: the config tree's baseUrl (the cordis.yml directory,
|
||||
// whose package declares every composed plugin as a dependency). The
|
||||
// modules package's own URL would miss sibling packages under pnpm's
|
||||
// isolated node_modules.
|
||||
if (ctx.baseUrl === undefined) {
|
||||
throw new Error('client-modules: ctx.baseUrl is unset — the node half needs the config-tree anchor to resolve plugin packages')
|
||||
}
|
||||
const require = createRequire(ctx.baseUrl)
|
||||
this.resolvePkgJson = spec => require.resolve(`${spec}/package.json`)
|
||||
|
||||
// Subscribe before seeding so a fiber arriving mid-activation lands in the
|
||||
// same dirty set (Set idempotence makes the overlap harmless). An entry-less
|
||||
// fiber is a child plugin or a manual mount — never a loader row; O(1) drop.
|
||||
@@ -716,31 +723,31 @@ export class ClientModuleRegistry extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
private resolveMeta(pkgName: string): PkgMeta | null {
|
||||
const cached = this.pkgMeta.get(pkgName)
|
||||
private resolveMeta(loaderName: string, baseUrl: string): ResolvedPkgMeta | null {
|
||||
const sourceKey = this.sourceKey(loaderName, baseUrl)
|
||||
const cached = this.pkgMeta.get(sourceKey)
|
||||
if (cached !== undefined) return cached
|
||||
let pkgPath: string
|
||||
try {
|
||||
pkgPath = this.resolvePkgJson(pkgName)
|
||||
} catch {
|
||||
const located = this.locatePkgJson(loaderName, baseUrl)
|
||||
if (located === undefined) {
|
||||
// Not a resolvable package root: loader builtins (cordis:include) and
|
||||
// subpath entries (…/gateway) land here — permanently not a client row.
|
||||
this.pkgMeta.set(pkgName, null)
|
||||
this.pkgMeta.set(sourceKey, null)
|
||||
return null
|
||||
}
|
||||
const { packageName, path: pkgPath } = located
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as Record<string, unknown>
|
||||
const dsh = pkg.dsh
|
||||
const decl = parseDshClient(
|
||||
pkgName,
|
||||
packageName,
|
||||
dsh !== null && typeof dsh === 'object' ? (dsh as Record<string, unknown>).client : undefined,
|
||||
)
|
||||
if (decl === undefined || decl.platform !== 'web') {
|
||||
this.pkgMeta.set(pkgName, null)
|
||||
this.pkgMeta.set(sourceKey, null)
|
||||
return null
|
||||
}
|
||||
const clientRel = clientExportOf(pkgName, pkg.exports)
|
||||
const clientRel = clientExportOf(packageName, pkg.exports)
|
||||
if (clientRel === undefined) {
|
||||
throw new Error(`client-modules: ${pkgName} declares dsh.client but exports no "./client" bundle`)
|
||||
throw new Error(`client-modules: ${packageName} declares dsh.client but exports no "./client" bundle`)
|
||||
}
|
||||
const meta: PkgMeta = {
|
||||
clientPath: join(dirname(pkgPath), clientRel),
|
||||
@@ -748,8 +755,87 @@ export class ClientModuleRegistry extends Service {
|
||||
external: decl.external ?? [],
|
||||
immediately: decl.immediately === true,
|
||||
}
|
||||
this.pkgMeta.set(pkgName, meta)
|
||||
return meta
|
||||
const resolved = { packageName, meta }
|
||||
this.pkgMeta.set(sourceKey, resolved)
|
||||
return resolved
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the manifest of the package the Loader mounts for a row. The row's
|
||||
* module location is authoritative: the specifier resolves through the same
|
||||
* Loader resolution that imported the row's host half — including any
|
||||
* active ESM hooks — and the nearest ancestor manifest declaring the name
|
||||
* owns the module. Config-anchor `require` resolution remains only for
|
||||
* runtimes without Node internals.
|
||||
* @param loaderName - module specifier of the loader row.
|
||||
* @param baseUrl - resolution base of the tree that owns the row.
|
||||
* @returns the manifest path, or `undefined` when the name resolves to no package root.
|
||||
*/
|
||||
private locatePkgJson(loaderName: string, baseUrl: string): { path: string; packageName: string } | undefined {
|
||||
if (loaderName.startsWith('cordis:')) return undefined
|
||||
const pathLike = loaderName.startsWith('.') || loaderName.startsWith('file:') || isAbsolute(loaderName)
|
||||
const expectedPackageName = pathLike ? undefined : exactPackageSpecifier(loaderName)
|
||||
if (!pathLike && expectedPackageName === undefined) return undefined
|
||||
const internal = this.ctx.loader.internal
|
||||
if (internal === undefined || typeof Reflect.get(internal, 'resolveSync') !== 'function') {
|
||||
if (expectedPackageName === undefined) {
|
||||
const moduleUrl = loaderName.startsWith('file:')
|
||||
? loaderName
|
||||
: isAbsolute(loaderName) ? pathToFileURL(loaderName).href : new URL(loaderName, baseUrl).href
|
||||
return this.nearestPackage(moduleUrl)
|
||||
}
|
||||
try {
|
||||
return {
|
||||
path: createRequire(baseUrl).resolve(`${expectedPackageName}/package.json`),
|
||||
packageName: expectedPackageName,
|
||||
}
|
||||
} catch {
|
||||
// Without Node internals the owning tree is the only resolver; an
|
||||
// unresolvable name is classified exactly as below.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
let moduleUrl: string
|
||||
try {
|
||||
moduleUrl = internal.version === 'v2'
|
||||
? internal.resolveSync(baseUrl, { specifier: loaderName, attributes: {} }).url
|
||||
: internal.resolveSync(loaderName, baseUrl, {}).url
|
||||
} catch {
|
||||
// The Loader cannot resolve the name: its row cannot have imported, so
|
||||
// the name is permanently not a client row.
|
||||
return undefined
|
||||
}
|
||||
return this.nearestPackage(moduleUrl, expectedPackageName)
|
||||
}
|
||||
|
||||
private nearestPackage(
|
||||
moduleUrl: string,
|
||||
expectedPackageName?: string,
|
||||
): { path: string; packageName: string } | undefined {
|
||||
if (!moduleUrl.startsWith('file:')) return undefined
|
||||
let dir = dirname(fileURLToPath(moduleUrl))
|
||||
while (true) {
|
||||
const candidate = join(dir, 'package.json')
|
||||
if (existsSync(candidate)) {
|
||||
try {
|
||||
const name = (JSON.parse(readFileSync(candidate, 'utf8')) as { name?: unknown }).name
|
||||
if (typeof name === 'string' && (expectedPackageName === undefined || name === expectedPackageName)) {
|
||||
return { path: candidate, packageName: name }
|
||||
}
|
||||
} catch {
|
||||
// An unreadable or malformed intermediate manifest cannot own the
|
||||
// module; keep walking toward the declaring package root.
|
||||
}
|
||||
}
|
||||
const parent = dirname(dir)
|
||||
if (parent === dir) break
|
||||
dir = parent
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
private sourceKey(loaderName: string, baseUrl: string): string {
|
||||
return `${baseUrl}\0${loaderName}`
|
||||
}
|
||||
|
||||
/** Capture the bundle stats before reading its bytes. */
|
||||
@@ -802,23 +888,32 @@ export class ClientModuleRegistry extends Service {
|
||||
|
||||
/** Reconcile one entry name against the live loader entries. @returns whether the table changed. */
|
||||
private processOne(entryName: string): boolean {
|
||||
let qualifies = false
|
||||
let activeEntry: Entry | undefined
|
||||
for (const entry of this.ctx.loader.entries()) {
|
||||
if (entry.options.name === entryName && entry.fiber !== undefined && !entry.disabled) {
|
||||
qualifies = true
|
||||
activeEntry = entry
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!qualifies) return this.table.delete(entryName)
|
||||
if (this.table.has(entryName)) return false
|
||||
const meta = this.resolveMeta(entryName)
|
||||
if (meta === null) return false
|
||||
if (activeEntry === undefined) return this.deleteLoaderEntry(entryName)
|
||||
const baseUrl = activeEntry.parent.tree.ctx.baseUrl
|
||||
if (baseUrl === undefined) {
|
||||
throw new Error(`client-modules: loader entry ${entryName} has no resolution base URL`)
|
||||
}
|
||||
const sourceKey = this.sourceKey(entryName, baseUrl)
|
||||
const resolved = this.resolveMeta(entryName, baseUrl)
|
||||
if (resolved === null) return this.deleteLoaderEntry(entryName)
|
||||
const { packageName, meta } = resolved
|
||||
if (this.table.get(packageName)?.sourceKey === sourceKey) return false
|
||||
this.deleteLoaderEntry(entryName)
|
||||
// The opaque initial rev rides the row until HMR observes a file change;
|
||||
// a fiber restart reuses the existing row without inspecting bytes.
|
||||
const snapshot = this.initialBundleSnapshot(entryName, meta.clientPath)
|
||||
const snapshot = this.initialBundleSnapshot(packageName, meta.clientPath)
|
||||
const rev = this.allocateInitialRevision()
|
||||
this.table.set(entryName, {
|
||||
entry: graphRow(entryName, rev, meta),
|
||||
this.table.set(packageName, {
|
||||
entry: graphRow(packageName, rev, meta),
|
||||
loaderName: entryName,
|
||||
sourceKey,
|
||||
meta,
|
||||
bundle: snapshot.bundle,
|
||||
baseline: snapshot.baseline,
|
||||
@@ -827,6 +922,16 @@ export class ClientModuleRegistry extends Service {
|
||||
return true
|
||||
}
|
||||
|
||||
private deleteLoaderEntry(loaderName: string): boolean {
|
||||
let changed = false
|
||||
for (const [packageName, record] of this.table) {
|
||||
if (record.loaderName !== loaderName) continue
|
||||
this.table.delete(packageName)
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
private flush(onError: (err: Error) => void): void {
|
||||
let changed = false
|
||||
for (const entryName of [...this.dirty]) {
|
||||
|
||||
@@ -58,13 +58,26 @@ function writeBuiltPackage(packageName: string, client: Record<string, unknown>)
|
||||
}
|
||||
|
||||
/** Construct the node-half service and capture its plugin-bundle route. */
|
||||
function constructWithRoute(packageNames: string[]): { service: ClientModuleRegistry; route: WebRoute } {
|
||||
function constructWithRoute(
|
||||
packageNames: string[],
|
||||
options: {
|
||||
contextBaseUrl?: string
|
||||
entryBaseUrl?: string
|
||||
internal?: NonNullable<Context['loader']['internal']>
|
||||
} = {},
|
||||
): { service: ClientModuleRegistry; route: WebRoute } {
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(root!).href + '/'
|
||||
ctx.baseUrl = options.contextBaseUrl ?? pathToFileURL(root!).href + '/'
|
||||
ctx.provide('loader', {
|
||||
internal: options.internal,
|
||||
*entries() {
|
||||
for (const packageName of packageNames) {
|
||||
yield { options: { name: packageName }, fiber: {}, disabled: false }
|
||||
yield {
|
||||
options: { name: packageName },
|
||||
fiber: {},
|
||||
disabled: false,
|
||||
parent: { tree: { ctx: { baseUrl: options.entryBaseUrl ?? ctx.baseUrl } } },
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -227,6 +240,65 @@ describe('HTML bootstrap facade', () => {
|
||||
})
|
||||
|
||||
describe('client bundle activation', () => {
|
||||
it.each(['v1', 'v2'] as const)(
|
||||
'resolves %s package metadata from the owning entry tree',
|
||||
(version) => {
|
||||
const packageName = `@fixture/entry-base-${version}`
|
||||
const clientPath = writePackage(packageName)
|
||||
const hostPath = join(dirname(clientPath), 'index.js')
|
||||
mkdirSync(dirname(hostPath), { recursive: true })
|
||||
writeFileSync(hostPath, 'export default {}\n')
|
||||
writeFileSync(clientPath, 'module.exports = {}\n')
|
||||
const contextBaseUrl = pathToFileURL(join(root!, 'profile')).href + '/'
|
||||
const entryBaseUrl = pathToFileURL(join(root!, 'overlay')).href + '/'
|
||||
const calls: unknown[][] = []
|
||||
const resolveSync = (...args: unknown[]) => {
|
||||
calls.push(args)
|
||||
return { format: 'module' as const, url: pathToFileURL(hostPath).href }
|
||||
}
|
||||
const internal = { version, resolveSync }
|
||||
|
||||
const { service } = constructWithRoute([packageName], {
|
||||
contextBaseUrl,
|
||||
entryBaseUrl,
|
||||
internal: internal as NonNullable<Context['loader']['internal']>,
|
||||
})
|
||||
|
||||
expect(calls).toEqual(version === 'v2'
|
||||
? [[entryBaseUrl, { specifier: packageName, attributes: {} }]]
|
||||
: [[packageName, entryBaseUrl, {}]])
|
||||
expect(service.clientPath(packageName)).toBe(clientPath)
|
||||
expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName])
|
||||
},
|
||||
)
|
||||
|
||||
it('derives the browser module id from a file entry owning manifest', () => {
|
||||
const packageName = '@fixture/file-entry'
|
||||
const clientPath = writePackage(packageName)
|
||||
const hostPath = join(dirname(clientPath), 'index.js')
|
||||
mkdirSync(dirname(hostPath), { recursive: true })
|
||||
writeFileSync(hostPath, 'export default {}\n')
|
||||
writeFileSync(clientPath, 'module.exports = {}\n')
|
||||
|
||||
const service = construct([pathToFileURL(hostPath).href])
|
||||
|
||||
expect(service.clientPath(packageName)).toBe(clientPath)
|
||||
expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName])
|
||||
})
|
||||
|
||||
it('uses owning-tree package resolution for an import-only Worker module loader', () => {
|
||||
const packageName = '@fixture/worker-loader'
|
||||
writeBuiltPackage(packageName, {})
|
||||
const internal = {
|
||||
version: 'worker',
|
||||
import: async () => ({}),
|
||||
} as unknown as NonNullable<Context['loader']['internal']>
|
||||
|
||||
const { service } = constructWithRoute([packageName], { internal })
|
||||
|
||||
expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName])
|
||||
})
|
||||
|
||||
it('allows sibling dsh roles', () => {
|
||||
const currentName = '@fixture/current-client-field'
|
||||
const clientPath = writePackage(currentName, {
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
# Development overlay for the experimental inspector: mount it per launch with
|
||||
# pnpm run demo:inspector (pnpm dsh web --patch ./packages/experimental/inspector/cordis.patch.yml)
|
||||
# A source launch resolves this workspace package through the tsconfig paths
|
||||
# facade and needs no installation. A built launch additionally needs the
|
||||
# package importable from the profile:
|
||||
# dsh plugin --profile web add link:<absolute path to this package directory>
|
||||
# (`link:`, not `file:` — `file:` re-installs the workspace:^ dependencies
|
||||
# inside the profile and fails). The package is private and ships with no
|
||||
# published dsh installation; a missing package fails loud at entry import.
|
||||
# Built-artifact overlay for the experimental inspector:
|
||||
# node apps/cli/lib/bin.js web --patch ./packages/experimental/inspector/cordis.patch.yml
|
||||
# The relative entry is anchored to this file, so the private package does not
|
||||
# need to be installed into the selected profile. Run `pnpm run build` first.
|
||||
|
||||
- insert:
|
||||
- id: experimental-inspector
|
||||
name: '@deepseek-ai/dsh-experimental-inspector'
|
||||
name: './lib/index.js'
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Source overlay for `pnpm run demo:inspector`. The relative entry is anchored
|
||||
# to this file and runs through the CLI's tsx loader without a profile install.
|
||||
|
||||
- insert:
|
||||
- id: experimental-inspector
|
||||
name: './src/index.ts'
|
||||
@@ -5,10 +5,12 @@ import { inspectorId } from '../../shared/identity.ts'
|
||||
import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts'
|
||||
import { bridgeCapabilities } from '../cdp/index.ts'
|
||||
|
||||
const CLIENT_SOURCE_STORAGE_KEY = 'dsh.experimental-inspector.client-source-id.v0'
|
||||
|
||||
/** Owns one browser realm's stable source id across transport reconnects. */
|
||||
export class ClientRealmSource {
|
||||
/** Logical source id retained across reconnecting transport generations. */
|
||||
readonly sourceId = inspectorId<'InspectorSourceId'>(`client-${randomUUID()}`, 'sourceId')
|
||||
readonly sourceId = sessionClientSourceId()
|
||||
|
||||
constructor(private readonly label: string) {}
|
||||
|
||||
@@ -29,6 +31,24 @@ export class ClientRealmSource {
|
||||
}
|
||||
}
|
||||
|
||||
function sessionClientSourceId(): InspectorSourceDescriptor['sourceId'] {
|
||||
const generated = inspectorId<'InspectorSourceId'>(`client-${randomUUID()}`, 'sourceId')
|
||||
try {
|
||||
const stored = sessionStorage.getItem(CLIENT_SOURCE_STORAGE_KEY)
|
||||
if (stored !== null) {
|
||||
try {
|
||||
return inspectorId<'InspectorSourceId'>(stored, 'sourceId')
|
||||
} catch {
|
||||
// Invalid page-owned storage is replaced with a fresh protocol identity below.
|
||||
}
|
||||
}
|
||||
sessionStorage.setItem(CLIENT_SOURCE_STORAGE_KEY, generated)
|
||||
} catch {
|
||||
// Disabled or unavailable session storage limits identity to this page lifetime.
|
||||
}
|
||||
return generated
|
||||
}
|
||||
|
||||
function clientOrigin(): string {
|
||||
const location = Reflect.get(globalThis, 'location') as unknown
|
||||
if (typeof location !== 'object' || location === null) return ''
|
||||
|
||||
@@ -66,9 +66,11 @@ describe('experimental Inspector Client plugin', () => {
|
||||
const nativeFetch = globalThis.fetch
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
FakeWebSocket.sockets.length = 0
|
||||
globalThis.WebSocket = nativeWebSocket
|
||||
globalThis.fetch = nativeFetch
|
||||
sessionStorage.clear()
|
||||
delete globalThis.__DSH_INSPECTOR__
|
||||
Reflect.deleteProperty(globalThis, '__DSH_BOOT__')
|
||||
})
|
||||
@@ -179,6 +181,50 @@ describe('experimental Inspector Client plugin', () => {
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps the logical source id when the Client plugin is recreated after a page refresh', async () => {
|
||||
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket
|
||||
globalThis.__DSH_INSPECTOR__ = bootstrap
|
||||
const firstContext = new Context()
|
||||
const firstFiber = firstContext.plugin({ apply })
|
||||
await firstFiber.await()
|
||||
const firstSocket = FakeWebSocket.sockets[0]!
|
||||
firstSocket.open()
|
||||
const firstOpen = JSON.parse(firstSocket.sent[0]!) as {
|
||||
source: { sourceId: string; generation: string }
|
||||
}
|
||||
await firstFiber.dispose()
|
||||
|
||||
const secondContext = new Context()
|
||||
const secondFiber = secondContext.plugin({ apply })
|
||||
await secondFiber.await()
|
||||
const secondSocket = FakeWebSocket.sockets[1]!
|
||||
secondSocket.open()
|
||||
const secondOpen = JSON.parse(secondSocket.sent[0]!) as {
|
||||
source: { sourceId: string; generation: string }
|
||||
}
|
||||
|
||||
expect(secondOpen.source.sourceId).toBe(firstOpen.source.sourceId)
|
||||
expect(secondOpen.source.generation).not.toBe(firstOpen.source.generation)
|
||||
await secondFiber.dispose()
|
||||
})
|
||||
|
||||
it('falls back to a page-lifetime source id when session storage is unavailable', async () => {
|
||||
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket
|
||||
globalThis.__DSH_INSPECTOR__ = bootstrap
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new DOMException('storage disabled', 'SecurityError')
|
||||
})
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin({ apply })
|
||||
await fiber.await()
|
||||
const socket = FakeWebSocket.sockets[0]!
|
||||
socket.open()
|
||||
const open = JSON.parse(socket.sent[0]!) as { source: { sourceId: string } }
|
||||
|
||||
expect(open.source.sourceId).toMatch(/^client-/u)
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('cancels an outstanding Client Runtime operation without sending a late response', async () => {
|
||||
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket
|
||||
globalThis.__DSH_INSPECTOR__ = bootstrap
|
||||
|
||||
@@ -45,6 +45,12 @@ export type Resolution =
|
||||
| { readonly kind: 'static'; readonly specifier: string; readonly factory: StaticModuleFactory }
|
||||
| { readonly kind: 'file'; readonly path: string }
|
||||
|
||||
/** Node-loader-compatible resolution returned through the Cordis internal seam. */
|
||||
export interface WorkerInternalResolution {
|
||||
readonly format: 'builtin' | 'commonjs' | 'json'
|
||||
readonly url: string
|
||||
}
|
||||
|
||||
interface ModuleRecord {
|
||||
readonly module: { exports: unknown }
|
||||
}
|
||||
@@ -121,6 +127,8 @@ export class WorkerModuleLoader {
|
||||
readonly internal: {
|
||||
readonly version: 'worker'
|
||||
import(specifier: string, parentURL?: string, attributes?: unknown): Promise<unknown>
|
||||
resolve(specifier: string, parentURL?: string, attributes?: unknown): Promise<WorkerInternalResolution>
|
||||
resolveSync(specifier: string, parentURL?: string, attributes?: unknown): WorkerInternalResolution
|
||||
}
|
||||
|
||||
constructor(options: WorkerModuleLoaderOptions) {
|
||||
@@ -133,12 +141,23 @@ export class WorkerModuleLoader {
|
||||
.sort(([left], [right]) => right.length - left.length)
|
||||
this.conditions = new Set(options.conditions ?? DEFAULT_CONDITIONS)
|
||||
this.als = createAlsRuntime(options.alsCausality)
|
||||
const resolveInternal = (specifier: string, parentURL?: string): WorkerInternalResolution => {
|
||||
const from = parentURL === undefined ? this.root : this.baseDirectoryOf(parentURL)
|
||||
const resolution = this.resolve(specifier, from)
|
||||
if (resolution.kind === 'static') return { format: 'builtin', url: resolution.specifier }
|
||||
return {
|
||||
format: resolution.path.endsWith('.json') ? 'json' : 'commonjs',
|
||||
url: pathToFileUrl(resolution.path),
|
||||
}
|
||||
}
|
||||
this.internal = {
|
||||
version: 'worker',
|
||||
import: async (specifier: string, parentURL?: string): Promise<unknown> => {
|
||||
const from = parentURL === undefined ? this.root : this.baseDirectoryOf(parentURL)
|
||||
return this.load(this.resolve(specifier, from))
|
||||
},
|
||||
resolve: async (specifier: string, parentURL?: string) => resolveInternal(specifier, parentURL),
|
||||
resolveSync: resolveInternal,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,23 @@ describe('the replacement table', () => {
|
||||
})
|
||||
|
||||
describe('module identity through the loader', () => {
|
||||
it('exposes async and synchronous resolution through the Cordis internal seam', async () => {
|
||||
const vfs = new MemoryVfs()
|
||||
vfs.seedDirectory('/dsh/node_modules/example')
|
||||
vfs.writeFileSync('/dsh/node_modules/example/package.json', JSON.stringify({ main: 'index.js' }))
|
||||
vfs.writeFileSync('/dsh/node_modules/example/index.js', 'module.exports = {}\n')
|
||||
const loader = new WorkerModuleLoader({ vfs, root: '/dsh', staticModules: createNodeBuiltins() })
|
||||
|
||||
expect(loader.internal.resolveSync('example', 'file:///dsh/app.js')).toEqual({
|
||||
format: 'commonjs',
|
||||
url: 'file:///dsh/node_modules/example/index.js',
|
||||
})
|
||||
await expect(loader.internal.resolve('node:fs', 'file:///dsh/app.js')).resolves.toEqual({
|
||||
format: 'builtin',
|
||||
url: 'node:fs',
|
||||
})
|
||||
})
|
||||
|
||||
it('hands the same instance to two requires of one specifier', () => {
|
||||
const require = loaderRequire()
|
||||
expect(require('node:events')).toBe(require('node:events'))
|
||||
|
||||
Reference in New Issue
Block a user