fix(inspector): serve Cordis DOM levels on demand

This commit is contained in:
imccyu
2026-08-27 16:16:28 +08:00
parent a031b95fdb
commit ef712e3006
5 changed files with 178 additions and 18 deletions
@@ -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/experimental/inspector/README.md
README.md: ff1aa86ee5920b1641d1a0b13e04b95b5691d036
README.zh.md: 213086441ac7280b22c4c0ffba7b48b863f69fb9
README.md: e10a68eed10c0bc71d26b5eacf9ee3eac4c6818d
README.zh.md: f6aceb5374730ff9879151980c60e54dad39fd89
+3 -1
View File
@@ -102,7 +102,9 @@ The Elements document has fixed `<host>` and `<clients>` containers. `<host>` co
Host and Client publish the same nested `CordisTreeSnapshot` type. Context and Fiber nodes carry opaque object handles for realm-local object lookup; Fiber nodes additionally carry Cordis `uid`. The Worker composes those realm snapshots into one `{ host, clients }` inspection tree. It assigns `BackendNodeId` values per source generation; each DevTools connection assigns its own `NodeId` values; `DOM.resolveNode` asks the owning Host or Client Runtime for a connection-local `RemoteObjectId`. `DOM.requestNode` maps that object id back to the same Elements node. `ctx.inspector.cordis.getTree()` and `DSHInspector.getCordisTree` read the detached consumer-neutral tree without routing handles or CDP ids.
Sources publish complete snapshots, while the Worker compares stable backend node identities before notifying DevTools. Unchanged snapshots emit no DOM event; additions, removals, and attribute changes use node-level CDP events, and sibling reordering replaces only that parent's children. Existing `NodeId` values and unaffected Elements expansion remain stable.
Node delivery is depth-limited per DevTools connection: `DOM.getDocument` serves three document levels when the caller omits `depth`, withheld levels advertise `childNodeCount`, and expansion fetches them through `DOM.requestChildNodes` (`depth: -1` for a whole subtree). NodeIds leaving through `DOM.performSearch`, `DOM.requestNode`, or `DOM.pushNodesByBackendIdsToFrontend` first push the not-yet-sent ancestor levels as `DOM.setChildNodes` events.
Sources publish complete snapshots, while the Worker compares stable backend node identities before notifying DevTools. Unchanged snapshots emit no DOM event; additions, removals, and attribute changes use node-level CDP events, inserted-node payloads withhold their subtree, and sibling reordering replaces only that parent's children. Existing `NodeId` values and unaffected Elements expansion remain stable.
When a Client disconnects, its Console execution context and live object ids are destroyed immediately. With disconnected-tree retention enabled, Elements keeps the last tree unchanged while connection state remains in the inspection model rather than becoming an unreviewed DOM attribute. Reconnection keeps the logical source id, creates a new synthetic CDP context id for the new transport generation, and replaces the stale tree after its complete snapshot arrives. The Worker retains at most `maxDisconnectedCordisTrees` such snapshots; zero removes them immediately.
+3 -1
View File
@@ -102,7 +102,9 @@ Elements document 包含固定的 `<host>` 与 `<clients>` 容器。`<host>` 包
Host 与 Client 发布同一种嵌套 `CordisTreeSnapshot` 类型。Context 与 Fiber 节点携带用于 realm-local 对象查询的不透明 object handleFiber 还携带 Cordis `uid`。Worker 把这些 realm snapshot 组合成一棵 `{ host, clients }` inspection tree。Worker 按 source generation 分配 `BackendNodeId`;每条 DevTools 连接分配自己的 `NodeId``DOM.resolveNode` 请求所属 Host 或 Client Runtime 生成连接本地 `RemoteObjectId``DOM.requestNode` 把该 object id 映射回同一个 Elements 节点。`ctx.inspector.cordis.getTree()``DSHInspector.getCordisTree` 读取不含 routing handle 或 CDP id 的 detached consumer-neutral tree。
source 仍发布完整 snapshotWorker 在通知 DevTools 前按稳定的 backend node identity 比较差异。无变化的 snapshot 不发送 DOM event;新增、移除和 attribute 变化使用节点级 CDP event,兄弟节点重排只替换对应 parent 的 children。现有 `NodeId` 与未受影响的 Elements 展开状态保持稳定
节点按 DevTools 连接做深度受限下发:调用方省略 `depth``DOM.getDocument` 提供三层 document,被扣留的层级通过 `childNodeCount` 声明数量,展开时经 `DOM.requestChildNodes` 获取(`depth: -1` 取整棵子树)。经 `DOM.performSearch``DOM.requestNode``DOM.pushNodesByBackendIdsToFrontend` 流出的 NodeId 会先把尚未下发的祖先层级以 `DOM.setChildNodes` event 推送出去
source 仍发布完整 snapshotWorker 在通知 DevTools 前按稳定的 backend node identity 比较差异。无变化的 snapshot 不发送 DOM event;新增、移除和 attribute 变化使用节点级 CDP event,插入节点的载荷扣留其子树,兄弟节点重排只替换对应 parent 的 children。现有 `NodeId` 与未受影响的 Elements 展开状态保持稳定。
Client 断联时,其 Console execution context 与 live object id 会立即销毁。启用断联树保留后,Elements 会原样保留最后一棵树;连接状态留在 inspection model 中,不会未经设计就成为 DOM attribute。重连会沿用逻辑 source id,为新的 transport generation 创建新的 synthetic CDP context id,并在完整 snapshot 到达后替换旧树。Worker 最多保留 `maxDisconnectedCordisTrees` 棵此类 snapshot;设为零会立即移除。
@@ -21,16 +21,27 @@ const READ_ONLY_METHODS = new Set([
'DOM.setOuterHTML', 'DOM.removeNode', 'DOM.moveTo', 'DOM.copyTo',
])
/**
* Children levels `DOM.getDocument` serves when the caller omits `depth`;
* deeper levels arrive through `DOM.requestChildNodes` on expand.
*/
const DEFAULT_DOCUMENT_DEPTH = 3
interface BoundDomObject {
readonly backendNodeId: CdpBackendNodeId
readonly sourceId: string
readonly generation: string
}
/** Connection-local NodeId, search, and RemoteObject mapping owner. */
/**
* Connection-local NodeId, search, and RemoteObject mapping owner. Node payloads are depth-limited;
* withheld levels are fetched through `DOM.requestChildNodes` or pushed with the ancestor chain
* when a NodeId leaves through search or object lookup.
*/
export class CordisDomSession {
private readonly nodeIdByBackend = new Map<CdpBackendNodeId, CdpNodeId>()
private readonly backendByNodeId = new Map<CdpNodeId, CdpBackendNodeId>()
private readonly childrenSent = new Set<CdpBackendNodeId>()
private readonly backendByObjectId = new Map<CdpRemoteObjectId, BoundDomObject>()
private readonly objectIdsByGroup = new Map<string, Set<CdpRemoteObjectId>>()
private readonly searches = new Map<string, CdpNodeId[]>()
@@ -118,21 +129,23 @@ export class CordisDomSession {
return {}
case 'DOM.getDocument':
this.enabled = true
return { root: this.serialize(this.backend.document().root, 0, true) }
return { root: this.serialize(this.backend.document().root, 0, depthParam(params.depth, DEFAULT_DOCUMENT_DEPTH), true) }
case 'DOM.requestChildNodes': {
const node = this.fromNodeId(params.nodeId)
const depth = depthParam(params.depth, 1)
this.childrenSent.add(node.backendNodeId)
this.transport.send({
method: 'DOM.setChildNodes',
params: {
parentId: numberParam(params.nodeId, 'nodeId'),
nodes: node.children.map(child => this.serialize(child, this.nodeId(node), true)),
nodes: node.children.map(child => this.serialize(child, this.nodeId(node), depth - 1, true)),
},
})
return {}
}
case 'DOM.describeNode': {
const node = this.selectNode(params)
return { node: this.serialize(node, this.parentNodeId(node), true) }
return { node: this.serialize(node, this.parentNodeId(node), depthParam(params.depth, 1), false) }
}
case 'DOM.getAttributes':
return { attributes: this.fromNodeId(params.nodeId).attributes.flat() }
@@ -144,7 +157,9 @@ export class CordisDomSession {
nodeIds: params.backendNodeIds.map((value) => {
if (!Number.isSafeInteger(value) || (value as number) < 1) return 0
const node = this.backend.document().byBackendId.get(cdpBackendNodeId(value, 'backendNodeId'))
return node === undefined ? 0 : this.nodeId(node)
if (node === undefined) return 0
this.pushNodePath(node)
return this.nodeId(node)
}),
}
}
@@ -156,6 +171,7 @@ export class CordisDomSession {
if (binding === undefined) throw new Error('RemoteObject is not a current Cordis node')
const node = this.backend.document().byBackendId.get(binding.backendNodeId)
if (node === undefined) throw new Error('Cordis node is no longer available')
this.pushNodePath(node)
return { nodeId: this.nodeId(node) }
}
case 'DOM.performSearch': {
@@ -169,9 +185,13 @@ export class CordisDomSession {
}
case 'DOM.getSearchResults': {
const ids = this.searches.get(stringParam(params.searchId, 'searchId')) ?? []
return {
nodeIds: ids.slice(nonNegativeInteger(params.fromIndex, 'fromIndex'), nonNegativeInteger(params.toIndex, 'toIndex')),
const nodeIds = ids.slice(nonNegativeInteger(params.fromIndex, 'fromIndex'), nonNegativeInteger(params.toIndex, 'toIndex'))
for (const nodeId of nodeIds) {
const backendId = this.backendByNodeId.get(nodeId)
const node = backendId === undefined ? undefined : this.backend.document().byBackendId.get(backendId)
if (node !== undefined) this.pushNodePath(node)
}
return { nodeIds }
}
case 'DOM.discardSearchResults':
this.searches.delete(stringParam(params.searchId, 'searchId'))
@@ -244,9 +264,13 @@ export class CordisDomSession {
return node
}
private serialize(node: CordisDomNode, parentId: CdpNodeId | 0, children: boolean): object {
private serialize(node: CordisDomNode, parentId: CdpNodeId | 0, remaining: number, delivery: boolean): object {
const nodeId = this.nodeId(node)
const document = node.name === '#document'
const withChildren = remaining > 0
// `DOM.describeNode` results are out-of-band descriptions the frontend does not merge into its tree,
// so only delivery payloads record which nodes already carried their children.
if (delivery && withChildren) this.childrenSent.add(node.backendNodeId)
return {
nodeId,
backendNodeId: node.backendNodeId,
@@ -257,11 +281,38 @@ export class CordisDomSession {
...(parentId === 0 ? {} : { parentId }),
...(document ? { documentURL: 'dsh://cordis', baseURL: 'dsh://cordis' } : {}),
childNodeCount: node.children.length,
...(children ? { children: node.children.map(child => this.serialize(child, nodeId, true)) } : {}),
...(withChildren ? { children: node.children.map(child => this.serialize(child, nodeId, remaining - 1, delivery)) } : {}),
attributes: node.attributes.flat(),
}
}
/** Deliver the not-yet-sent ancestor levels of one node so its NodeId attaches to the frontend tree. */
private pushNodePath(node: CordisDomNode): void {
const document = this.backend.document()
const chain: CordisDomNode[] = []
let backendId = document.parentByBackendId.get(node.backendNodeId)
while (backendId !== undefined) {
const parent = document.byBackendId.get(backendId)
if (parent === undefined) break
chain.unshift(parent)
backendId = document.parentByBackendId.get(parent.backendNodeId)
}
for (const ancestor of chain) {
if (this.childrenSent.has(ancestor.backendNodeId)) continue
const parentId = this.nodeId(ancestor)
this.childrenSent.add(ancestor.backendNodeId)
this.transport.send({
method: 'DOM.setChildNodes',
params: { parentId, nodes: ancestor.children.map(child => this.serialize(child, parentId, 0, true)) },
})
}
}
private forgetSubtree(node: CordisDomNode): void {
this.childrenSent.delete(node.backendNodeId)
for (const child of node.children) this.forgetSubtree(child)
}
private nodeId(node: CordisDomNode): CdpNodeId {
let nodeId = this.nodeIdByBackend.get(node.backendNodeId)
if (nodeId === undefined) {
@@ -285,6 +336,7 @@ export class CordisDomSession {
this.backendByObjectId.clear()
this.objectIdsByGroup.clear()
this.searches.clear()
this.childrenSent.clear()
}
private updateDocument(event: CordisDomChange): void {
@@ -309,12 +361,14 @@ export class CordisDomSession {
? 0
: this.nodeIdByBackend.get(mutation.previousBackendNodeId)
if (previousNodeId === undefined) return
// A reconnected source reuses backend ids; the collapsed payload resets any earlier delivery record.
this.forgetSubtree(mutation.node)
this.transport.send({
method: 'DOM.childNodeInserted',
params: {
parentNodeId,
previousNodeId,
node: this.serialize(mutation.node, parentNodeId, true),
node: this.serialize(mutation.node, parentNodeId, 0, true),
},
})
return
@@ -322,6 +376,7 @@ export class CordisDomSession {
case 'child-removed': {
const parentNodeId = this.nodeIdByBackend.get(mutation.parentBackendNodeId)
const nodeId = this.nodeIdByBackend.get(mutation.node.backendNodeId)
this.forgetSubtree(mutation.node)
if (parentNodeId === undefined || nodeId === undefined) return
this.transport.send({ method: 'DOM.childNodeRemoved', params: { parentNodeId, nodeId } })
return
@@ -329,11 +384,14 @@ export class CordisDomSession {
case 'children-replaced': {
const parentNodeId = this.nodeIdByBackend.get(mutation.parentBackendNodeId)
if (parentNodeId === undefined) return
// Replacement payloads carry no grandchildren, so the frontend forgets any it knew below this parent.
for (const child of mutation.children) this.forgetSubtree(child)
this.childrenSent.add(mutation.parentBackendNodeId)
this.transport.send({
method: 'DOM.setChildNodes',
params: {
parentId: parentNodeId,
nodes: mutation.children.map(child => this.serialize(child, parentNodeId, true)),
nodes: mutation.children.map(child => this.serialize(child, parentNodeId, 0, true)),
},
})
return
@@ -367,6 +425,9 @@ export class CordisDomSession {
this.nodeIdByBackend.delete(backendNodeId)
this.backendByNodeId.delete(nodeId)
}
for (const backendNodeId of this.childrenSent) {
if (!document.byBackendId.has(backendNodeId)) this.childrenSent.delete(backendNodeId)
}
for (const [objectId, binding] of this.backendByObjectId) {
const node = document.byBackendId.get(binding.backendNodeId)
const source = node?.object?.source
@@ -417,6 +478,13 @@ function numberParam(value: unknown, name: string): number {
return value as number
}
function depthParam(value: unknown, fallback: number): number {
if (value === undefined) return fallback
if (value === -1) return Number.POSITIVE_INFINITY
if (!Number.isSafeInteger(value) || (value as number) < 1) throw new Error('depth must be -1 or a positive integer')
return value as number
}
function cdpNodeId(value: unknown, name: string): CdpNodeId {
if (!Number.isSafeInteger(value)) throw new Error(`${name} must be an integer`)
return cdpNumericId<'CdpNodeId'>(value as number, name)
@@ -29,6 +29,7 @@ interface CdpNode {
readonly backendNodeId: number
readonly localName: string
readonly attributes?: string[]
readonly childNodeCount?: number
readonly children?: CdpNode[]
}
@@ -359,7 +360,7 @@ describe('Cordis tree inspection', () => {
let document: CdpNode | undefined
await vi.waitFor(async () => {
const response = await cdp!.call('DOM.getDocument')
const response = await cdp!.call('DOM.getDocument', { depth: -1 })
expect(response.error).toBeUndefined()
document = response.result?.root as CdpNode
expect(hostContainer(document)).toBeDefined()
@@ -488,7 +489,7 @@ describe('Cordis tree inspection', () => {
const firstResolved = await cdp.call('DOM.resolveNode', { backendNodeId: clientNode.backendNodeId })
const firstObjectId = (firstResolved.result?.object as Record<string, unknown>).objectId
secondCdp = await CdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
const secondDocument = (await secondCdp.call('DOM.getDocument')).result?.root as CdpNode
const secondDocument = (await secondCdp.call('DOM.getDocument', { depth: -1 })).result?.root as CdpNode
const secondNode = walk(secondDocument).find(node => node.backendNodeId === clientNode.backendNodeId)
expect(secondNode).toBeDefined()
const secondResolved = await secondCdp.call('DOM.resolveNode', { backendNodeId: clientNode.backendNodeId })
@@ -506,7 +507,7 @@ describe('Cordis tree inspection', () => {
expect(events.some(event => event.method === 'DOM.documentUpdated')).toBe(false)
})
const disconnectedDocument = (await cdp.call('DOM.getDocument')).result?.root as CdpNode
const disconnectedDocument = (await cdp.call('DOM.getDocument', { depth: -1 })).result?.root as CdpNode
const disconnectedClient = clientContainers(disconnectedDocument)[0]
expect(disconnectedClient).toBeDefined()
expect(walk(disconnectedClient!).find(node => node.backendNodeId === clientNode.backendNodeId)?.nodeId)
@@ -531,13 +532,18 @@ describe('Cordis tree inspection', () => {
let offset = cdp.events.length
clientSource = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Incremental Client' })
let insertedClient: CdpNode | undefined
await vi.waitFor(() => {
const events = cdp!.events.slice(offset)
const inserted = events.find(event => event.method === 'DOM.childNodeInserted')
expect(inserted?.params?.parentNodeId).toBe(clientsNode.nodeId)
expect(inserted?.params?.node).toMatchObject({ localName: 'client' })
expect(events.some(event => event.method === 'DOM.documentUpdated')).toBe(false)
insertedClient = inserted?.params?.node as CdpNode
})
// The collapsed insert payload withholds the realm subtree; expand it to follow deeper changes.
expect(insertedClient?.children).toBeUndefined()
await cdp.call('DOM.requestChildNodes', { nodeId: insertedClient!.nodeId, depth: -1 })
const firstTree = (await cdp.call('DSHInspector.getCordisTree')).result?.tree as {
clients: Array<{ revision: number }>
@@ -575,6 +581,88 @@ describe('Cordis tree inspection', () => {
})
})
it('serves three document levels by default and withheld levels on demand', async () => {
inspector = await startInspector({ port: 0, captureFetch: false, maxCordisNodes: 100 })
const host = new Context()
let innerFiber: { uid: number | null } | undefined
const outer = host.plugin({
name: 'outer',
apply(ctx: Context) { innerFiber = ctx.plugin({ name: 'inner', apply() {} }) },
})
fibers.push(outer)
await outer.await()
const innerUid = innerFiber?.uid
if (innerFiber === undefined || innerUid === null || innerUid === undefined) {
throw new Error('nested plugin did not register a uid')
}
observers.push(publishHostCordisTree(host, inspector.source, { maxNodes: 100, maxBytes: 64 * 1_024 }))
cdp = await CdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
// Default document depth ends at the first Fiber layer: children withheld, count advertised.
let outerNode: CdpNode | undefined
await vi.waitFor(async () => {
const document = (await cdp!.call('DOM.getDocument')).result?.root as CdpNode
outerNode = hostContainer(document)?.children?.[0]?.children
?.find(node => node.localName === 'fiber' && node.attributes?.includes(String(outer.uid)))
expect(outerNode).toBeDefined()
})
expect(outerNode?.children).toBeUndefined()
expect(outerNode?.childNodeCount).toBe(1)
// Expanding serves exactly one more level by default.
let offset = cdp.events.length
await cdp.call('DOM.requestChildNodes', { nodeId: outerNode!.nodeId })
const expanded = cdp.events.slice(offset).find(event => event.method === 'DOM.setChildNodes')
expect(expanded?.params?.parentId).toBe(outerNode!.nodeId)
const outerContext = (expanded?.params?.nodes as CdpNode[])[0]
expect(outerContext).toMatchObject({ localName: 'context', childNodeCount: 1 })
expect(outerContext?.children).toBeUndefined()
// Expand-recursively requests the entire subtree.
offset = cdp.events.length
await cdp.call('DOM.requestChildNodes', { nodeId: outerNode!.nodeId, depth: -1 })
const recursive = cdp.events.slice(offset).find(event => event.method === 'DOM.setChildNodes')
const recursiveContext = (recursive?.params?.nodes as CdpNode[])[0]
expect(recursiveContext?.children?.[0]).toMatchObject({
localName: 'fiber',
attributes: ['uid', String(innerUid)],
})
expect((await cdp.call('DOM.getDocument', { depth: 0 })).error?.message).toContain('depth')
// A NodeId leaving through search or object lookup pushes the not-yet-sent ancestor levels first.
secondCdp = await CdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
await secondCdp.call('Runtime.enable')
const secondDocument = (await secondCdp.call('DOM.getDocument')).result?.root as CdpNode
const secondOuter = walk(secondDocument).find(node => node.attributes?.includes(String(outer.uid)))
const described = (await secondCdp.call('DOM.describeNode', { nodeId: secondOuter?.nodeId })).result?.node as CdpNode
expect(described.children?.[0]?.localName).toBe('context')
expect(described.children?.[0]?.children).toBeUndefined()
const search = await secondCdp.call('DOM.performSearch', { query: `uid=${JSON.stringify(String(innerUid))}` })
expect(search.result?.resultCount).toBe(1)
offset = secondCdp.events.length
const results = await secondCdp.call('DOM.getSearchResults', {
searchId: search.result?.searchId,
fromIndex: 0,
toIndex: 1,
})
const innerNodeId = (results.result?.nodeIds as number[])[0]
const pushed = secondCdp.events.slice(offset).filter(event => event.method === 'DOM.setChildNodes')
expect(pushed).toHaveLength(2)
await expect(secondCdp.call('DOM.getAttributes', { nodeId: innerNodeId })).resolves.toMatchObject({
result: { attributes: ['uid', String(innerUid)] },
})
Reflect.set(globalThis, '__cordisHostProbe', innerFiber)
const evaluated = await secondCdp.call('Runtime.evaluate', { expression: 'globalThis.__cordisHostProbe' })
expect(evaluated.result?.result).toMatchObject({ subtype: 'node', className: 'Fiber' })
offset = secondCdp.events.length
await expect(secondCdp.call('DOM.requestNode', {
objectId: (evaluated.result?.result as Record<string, unknown>).objectId,
})).resolves.toMatchObject({ result: { nodeId: innerNodeId } })
expect(secondCdp.events.slice(offset).some(event => event.method === 'DOM.setChildNodes')).toBe(false)
})
it('restores a disconnected Client tree from a new transport generation', async () => {
inspector = await startInspector({
port: 0,