mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
feat(inspector): serve Runtime through a CDP Worker
This commit is contained in:
@@ -0,0 +1,304 @@
|
|||||||
|
/** Worker-owned HTTP discovery, DevTools CDP, and Client-ingest endpoints. */
|
||||||
|
|
||||||
|
import { createServer, type IncomingMessage, type Server } from 'node:http'
|
||||||
|
import type { AddressInfo } from 'node:net'
|
||||||
|
import type { Duplex } from 'node:stream'
|
||||||
|
import { WebSocketServer, type RawData, type WebSocket } from 'ws'
|
||||||
|
import type { InspectorWorkerConfig } from '../../shared/bridge/messages/control.ts'
|
||||||
|
import type { WorkerToSourceFrame } from '../../shared/bridge/messages/observation.ts'
|
||||||
|
import { CdpSession } from '../cdp/session.ts'
|
||||||
|
import type { CdpTransport } from '../cdp/protocol.ts'
|
||||||
|
import type { NetworkDomain } from '../cdp/domains/network/session.ts'
|
||||||
|
import type { CordisDomBackend } from '../cdp/domains/dom/index.ts'
|
||||||
|
import type { CordisRuntimeTreeReader } from '../../shared/cordis/reader.ts'
|
||||||
|
import type { InspectorQueryRouter } from '../inspection/query-router.ts'
|
||||||
|
import type { InspectorRealmRegistry } from '../inspection/realm-store.ts'
|
||||||
|
import type { InspectorSourceRegistry, SourceConnection } from './hub.ts'
|
||||||
|
|
||||||
|
/** Bound endpoint information returned to the Host controller. */
|
||||||
|
export interface InspectorEndpointInfo {
|
||||||
|
readonly host: string
|
||||||
|
readonly port: number
|
||||||
|
readonly targetId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Worker-owned network endpoint. */
|
||||||
|
export class InspectorEndpoint {
|
||||||
|
private server: Server | undefined
|
||||||
|
private readonly cdpServer: WebSocketServer
|
||||||
|
private readonly ingestServer: WebSocketServer
|
||||||
|
private readonly cdpSessions = new Map<WebSocket, CdpSession>()
|
||||||
|
private readonly ingestConnections = new Map<WebSocket, SourceConnection>()
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly config: InspectorWorkerConfig,
|
||||||
|
private readonly sources: InspectorSourceRegistry,
|
||||||
|
private readonly network: NetworkDomain,
|
||||||
|
private readonly realms: InspectorRealmRegistry,
|
||||||
|
private readonly cordisDom: CordisDomBackend,
|
||||||
|
private readonly cordisTrees: CordisRuntimeTreeReader,
|
||||||
|
private readonly queries: InspectorQueryRouter,
|
||||||
|
) {
|
||||||
|
this.cdpServer = new WebSocketServer({ noServer: true, maxPayload: config.maxSourceFrameBytes })
|
||||||
|
this.ingestServer = new WebSocketServer({ noServer: true, maxPayload: config.maxSourceFrameBytes })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bind the loopback endpoint.
|
||||||
|
* @returns The actual bound address and target id.
|
||||||
|
*/
|
||||||
|
async start(): Promise<InspectorEndpointInfo> {
|
||||||
|
let candidate = this.config.startPort
|
||||||
|
while (true) {
|
||||||
|
const server = this.createServer()
|
||||||
|
this.server = server
|
||||||
|
try {
|
||||||
|
const address = await listen(server, candidate, this.config.host)
|
||||||
|
server.on('error', () => {
|
||||||
|
// An established server error is connection-local or reported by
|
||||||
|
// the operating system; active sockets retain their own handlers.
|
||||||
|
})
|
||||||
|
return { host: this.config.host, port: address.port, targetId: this.config.targetId }
|
||||||
|
} catch (error) {
|
||||||
|
this.server = undefined
|
||||||
|
if (!isAddressInUse(error) || candidate === 0) throw error
|
||||||
|
if (candidate === 65_535) {
|
||||||
|
throw new Error(`inspector: no available port from ${String(this.config.startPort)} through 65535`, {
|
||||||
|
cause: error,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
candidate += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop admission, dispose CDP sessions, terminate sockets, and await server close. */
|
||||||
|
async close(): Promise<void> {
|
||||||
|
const server = this.requireServer()
|
||||||
|
for (const [socket, session] of this.cdpSessions) {
|
||||||
|
session.close()
|
||||||
|
socket.terminate()
|
||||||
|
}
|
||||||
|
this.cdpSessions.clear()
|
||||||
|
for (const [socket, connection] of this.ingestConnections) {
|
||||||
|
this.sources.disconnect(connection, 'Client ingest endpoint stopped')
|
||||||
|
socket.terminate()
|
||||||
|
}
|
||||||
|
this.ingestConnections.clear()
|
||||||
|
await Promise.all([
|
||||||
|
closeWebSocketServer(this.cdpServer),
|
||||||
|
closeWebSocketServer(this.ingestServer),
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
server.close(() => { resolve() })
|
||||||
|
server.closeAllConnections()
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleHttp(request: IncomingMessage, response: import('node:http').ServerResponse): void {
|
||||||
|
const pathname = new URL(request.url ?? '/', 'http://inspector.invalid').pathname
|
||||||
|
if (pathname === '/json' || pathname === '/json/list') {
|
||||||
|
this.json(response, [this.target()])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (pathname === '/json/version') {
|
||||||
|
this.json(response, {
|
||||||
|
Browser: 'dsh-experimental-inspector/0',
|
||||||
|
'Protocol-Version': '1.3',
|
||||||
|
webSocketDebuggerUrl: this.cdpUrl(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
|
||||||
|
response.end('not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleUpgrade(request: IncomingMessage, socket: Duplex, head: Buffer): void {
|
||||||
|
let pathname: string
|
||||||
|
try {
|
||||||
|
pathname = new URL(request.url ?? '/', 'http://inspector.invalid').pathname
|
||||||
|
} catch {
|
||||||
|
socket.destroy()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (pathname === `/devtools/page/${this.config.targetId}`) {
|
||||||
|
this.cdpServer.handleUpgrade(request, socket, head, (ws) => { this.acceptCdp(ws) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (pathname === '/ingest') {
|
||||||
|
if (!this.authorizedClient(request)) {
|
||||||
|
socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.ingestServer.handleUpgrade(request, socket, head, (ws) => { this.acceptIngest(ws) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
socket.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private acceptCdp(socket: WebSocket): void {
|
||||||
|
const transport: CdpTransport = {
|
||||||
|
send: (payload) => {
|
||||||
|
if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(payload))
|
||||||
|
},
|
||||||
|
close: () => { socket.close(1008, 'invalid CDP request') },
|
||||||
|
}
|
||||||
|
const session = new CdpSession(
|
||||||
|
transport,
|
||||||
|
{ targetId: this.config.targetId, title: 'DeepSeek Harness Host' },
|
||||||
|
this.sources,
|
||||||
|
this.network,
|
||||||
|
this.realms,
|
||||||
|
this.cordisDom,
|
||||||
|
this.cordisTrees,
|
||||||
|
)
|
||||||
|
this.cdpSessions.set(socket, session)
|
||||||
|
socket.on('message', (data) => {
|
||||||
|
try {
|
||||||
|
session.receive(JSON.parse(rawText(data)) as unknown)
|
||||||
|
} catch {
|
||||||
|
socket.close(1008, 'CDP frame must be JSON')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
socket.once('close', () => {
|
||||||
|
this.cdpSessions.delete(socket)
|
||||||
|
session.close()
|
||||||
|
})
|
||||||
|
socket.on('error', () => {
|
||||||
|
// The close event performs connection-owned cleanup.
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private acceptIngest(socket: WebSocket): void {
|
||||||
|
const queryPeer = this.queries.open({
|
||||||
|
send: (frame) => {
|
||||||
|
if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(frame))
|
||||||
|
},
|
||||||
|
close: (code, reason) => { socket.close(code, reason) },
|
||||||
|
})
|
||||||
|
const connection: SourceConnection = {
|
||||||
|
kind: 'client',
|
||||||
|
send: (frame: WorkerToSourceFrame) => {
|
||||||
|
if (socket.readyState !== socket.OPEN) return
|
||||||
|
socket.send(JSON.stringify(frame))
|
||||||
|
if (frame.t === 'source/accepted') queryPeer.accept(frame.sourceId, frame.generation)
|
||||||
|
},
|
||||||
|
close: (code, reason) => { socket.close(code, reason.slice(0, 123)) },
|
||||||
|
}
|
||||||
|
this.ingestConnections.set(socket, connection)
|
||||||
|
socket.on('message', (data) => {
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(rawText(data)) as unknown
|
||||||
|
if (!queryPeer.receive(value)) this.sources.receive(connection, value)
|
||||||
|
} catch {
|
||||||
|
connection.close(1008, 'source frame must be JSON')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
socket.once('close', () => {
|
||||||
|
this.ingestConnections.delete(socket)
|
||||||
|
queryPeer.close()
|
||||||
|
this.sources.disconnect(connection, 'Client source disconnected')
|
||||||
|
})
|
||||||
|
socket.on('error', () => {
|
||||||
|
// The close event performs connection-owned cleanup.
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private authorizedClient(request: IncomingMessage): boolean {
|
||||||
|
const protocols = (request.headers['sec-websocket-protocol'] ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map(value => value.trim())
|
||||||
|
if (!protocols.includes(this.config.clientToken)) return false
|
||||||
|
const origin = request.headers.origin
|
||||||
|
if (origin === undefined) return true
|
||||||
|
if (this.config.clientOrigins.includes(origin)) return true
|
||||||
|
try {
|
||||||
|
const hostname = new URL(origin).hostname
|
||||||
|
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1'
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private target(): object {
|
||||||
|
return {
|
||||||
|
id: this.config.targetId,
|
||||||
|
type: 'page',
|
||||||
|
title: 'DeepSeek Harness Host',
|
||||||
|
description: 'Experimental cross-realm Inspector target',
|
||||||
|
url: 'dsh://host',
|
||||||
|
webSocketDebuggerUrl: this.cdpUrl(),
|
||||||
|
devtoolsFrontendUrl: `devtools://devtools/bundled/devtools_app.html?ws=${this.config.host}:${this.boundPort()}/devtools/page/${this.config.targetId}&panel=elements&noJavaScriptCompletion=true`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private cdpUrl(): string {
|
||||||
|
return `ws://${this.config.host}:${String(this.boundPort())}/devtools/page/${this.config.targetId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private boundPort(): number {
|
||||||
|
const address = this.requireServer().address()
|
||||||
|
if (address === null || typeof address === 'string') {
|
||||||
|
throw new Error('inspector: endpoint is not bound to a TCP port')
|
||||||
|
}
|
||||||
|
return address.port
|
||||||
|
}
|
||||||
|
|
||||||
|
private createServer(): Server {
|
||||||
|
const server = createServer((request, response) => { this.handleHttp(request, response) })
|
||||||
|
server.on('upgrade', (request, socket, head) => { this.handleUpgrade(request, socket, head) })
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireServer(): Server {
|
||||||
|
if (this.server === undefined) throw new Error('inspector: endpoint is not started')
|
||||||
|
return this.server
|
||||||
|
}
|
||||||
|
|
||||||
|
private json(response: import('node:http').ServerResponse, value: unknown): void {
|
||||||
|
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
|
||||||
|
response.end(JSON.stringify(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function listen(server: Server, port: number, host: string): Promise<AddressInfo> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const finish = (): void => {
|
||||||
|
server.off('error', onError)
|
||||||
|
server.off('listening', onListening)
|
||||||
|
}
|
||||||
|
const onError = (error: Error): void => {
|
||||||
|
finish()
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
const onListening = (): void => {
|
||||||
|
finish()
|
||||||
|
const address = server.address()
|
||||||
|
if (address === null || typeof address === 'string') {
|
||||||
|
reject(new Error('inspector: endpoint did not bind a TCP port'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve(address)
|
||||||
|
}
|
||||||
|
server.once('error', onError)
|
||||||
|
server.once('listening', onListening)
|
||||||
|
server.listen(port, host)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAddressInUse(error: unknown): boolean {
|
||||||
|
return error instanceof Error && (error as NodeJS.ErrnoException).code === 'EADDRINUSE'
|
||||||
|
}
|
||||||
|
|
||||||
|
function rawText(data: RawData): string {
|
||||||
|
const bytes = data instanceof ArrayBuffer
|
||||||
|
? Buffer.from(new Uint8Array(data))
|
||||||
|
: Array.isArray(data) ? Buffer.concat(data) : data
|
||||||
|
return bytes.toString('utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeWebSocketServer(server: WebSocketServer): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
server.close(() => { resolve() })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
/** Worker-owned source generations, observation dispatch, and extension transport. */
|
||||||
|
|
||||||
|
import { jsonByteLength, type InspectorJsonValue } from '../../shared/json.ts'
|
||||||
|
import {
|
||||||
|
INSPECTOR_PROTOCOL_VERSION,
|
||||||
|
parseSourceFrame,
|
||||||
|
type InspectorRecordInput,
|
||||||
|
type InspectorSourceDescriptor,
|
||||||
|
type InspectorSourceKind,
|
||||||
|
type SourceToWorkerFrame,
|
||||||
|
type WorkerToSourceFrame,
|
||||||
|
} from '../../shared/bridge/messages/observation.ts'
|
||||||
|
import type { ClientConsoleEventFrame, ClientRuntimeResponseFrame } from '../../shared/bridge/messages/runtime/index.ts'
|
||||||
|
import type { ClientSourceResponseFrame } from '../../shared/bridge/messages/sources/index.ts'
|
||||||
|
|
||||||
|
/** One validated record with its source-local sequence. */
|
||||||
|
export interface IngestedInspectorRecord extends InspectorRecordInput {
|
||||||
|
readonly sequence: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One connected source's reply and close operations. */
|
||||||
|
export interface SourceConnection {
|
||||||
|
readonly kind: InspectorSourceKind
|
||||||
|
send(frame: WorkerToSourceFrame): void
|
||||||
|
close(code: number, reason: string): void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Consumer of source lifecycle and records. */
|
||||||
|
export interface InspectorRecordConsumer {
|
||||||
|
readonly topics: ReadonlySet<string>
|
||||||
|
replace(source: InspectorSourceDescriptor, records: readonly IngestedInspectorRecord[]): void
|
||||||
|
append(source: InspectorSourceDescriptor, records: readonly IngestedInspectorRecord[]): void
|
||||||
|
close(source: InspectorSourceDescriptor, reason: string): void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SourceState {
|
||||||
|
readonly source: InspectorSourceDescriptor
|
||||||
|
readonly topics: ReadonlySet<string>
|
||||||
|
readonly connection: SourceConnection
|
||||||
|
expectedSequence: number
|
||||||
|
dropped: number
|
||||||
|
readonly topicCounts: Map<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Source lifecycle and typed extension frames observed inside the Worker. */
|
||||||
|
export type InspectorSourceEvent =
|
||||||
|
| { readonly type: 'opened'; readonly source: InspectorSourceDescriptor }
|
||||||
|
| { readonly type: 'closed'; readonly source: InspectorSourceDescriptor; readonly reason: string }
|
||||||
|
| {
|
||||||
|
readonly type: 'client-runtime-response'
|
||||||
|
readonly source: InspectorSourceDescriptor
|
||||||
|
readonly frame: ClientRuntimeResponseFrame
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly type: 'client-console-event'
|
||||||
|
readonly source: InspectorSourceDescriptor
|
||||||
|
readonly frame: ClientConsoleEventFrame
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly type: 'client-source-response'
|
||||||
|
readonly source: InspectorSourceDescriptor
|
||||||
|
readonly frame: ClientSourceResponseFrame
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read-only diagnostic for `DSHInspector.getSources`. */
|
||||||
|
export interface InspectorSourceView {
|
||||||
|
readonly sourceId: string
|
||||||
|
readonly generation: string
|
||||||
|
readonly kind: InspectorSourceKind
|
||||||
|
readonly label: string
|
||||||
|
readonly capabilities: readonly string[]
|
||||||
|
readonly expectedSequence: number
|
||||||
|
readonly dropped: number
|
||||||
|
readonly topics: Readonly<Record<string, number>>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serial Worker-side owner of every Host and Client source generation. */
|
||||||
|
export class InspectorSourceRegistry {
|
||||||
|
private readonly sources = new Map<string, SourceState>()
|
||||||
|
private readonly statusListeners = new Set<() => void>()
|
||||||
|
private readonly eventListeners = new Set<(event: InspectorSourceEvent) => void>()
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly consumers: readonly InspectorRecordConsumer[],
|
||||||
|
private readonly maxFrameBytes: number,
|
||||||
|
private readonly maxRecordsPerFrame: number,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse and apply one frame; malformed input closes only its source transport.
|
||||||
|
* @param connection - Carrier that delivered the frame.
|
||||||
|
* @param value - Untrusted decoded frame.
|
||||||
|
*/
|
||||||
|
receive(connection: SourceConnection, value: unknown): void {
|
||||||
|
try {
|
||||||
|
const frame = parseSourceFrame(value, this.maxRecordsPerFrame)
|
||||||
|
if (jsonByteLength(frame as unknown as InspectorJsonValue) > this.maxFrameBytes) {
|
||||||
|
throw new Error(`inspector protocol: source frame exceeds ${String(this.maxFrameBytes)} bytes`)
|
||||||
|
}
|
||||||
|
this.apply(connection, frame)
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
connection.send({ v: INSPECTOR_PROTOCOL_VERSION, t: 'source/rejected', code: 'invalid-frame', message })
|
||||||
|
connection.close(1008, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove every generation carried by a closed connection.
|
||||||
|
* @param connection - Closed source carrier.
|
||||||
|
* @param reason - Diagnostic propagated to domain consumers.
|
||||||
|
*/
|
||||||
|
disconnect(connection: SourceConnection, reason: string): void {
|
||||||
|
for (const [sourceId, state] of this.sources) {
|
||||||
|
if (state.connection !== connection) continue
|
||||||
|
this.sources.delete(sourceId)
|
||||||
|
for (const consumer of this.consumers) consumer.close(state.source, reason)
|
||||||
|
this.emit({ type: 'closed', source: state.source, reason })
|
||||||
|
}
|
||||||
|
this.notifyStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read current source status for the diagnostic CDP domain.
|
||||||
|
* @returns A detached status row for every active source.
|
||||||
|
*/
|
||||||
|
describe(): InspectorSourceView[] {
|
||||||
|
return [...this.sources.values()].map(state => ({
|
||||||
|
sourceId: state.source.sourceId,
|
||||||
|
generation: state.source.generation,
|
||||||
|
kind: state.source.kind,
|
||||||
|
label: state.source.label,
|
||||||
|
capabilities: state.source.capabilities.map(capability => capability.type),
|
||||||
|
expectedSequence: state.expectedSequence,
|
||||||
|
dropped: state.dropped,
|
||||||
|
topics: Object.fromEntries(state.topicCounts),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to source status changes.
|
||||||
|
* @param listener - Status observer.
|
||||||
|
* @returns A disposer that removes the observer.
|
||||||
|
*/
|
||||||
|
subscribeStatus(listener: () => void): () => void {
|
||||||
|
this.statusListeners.add(listener)
|
||||||
|
return () => { this.statusListeners.delete(listener) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to source admission, removal, and typed extension frames.
|
||||||
|
* @param listener - Source protocol observer.
|
||||||
|
* @returns A disposer that removes the observer.
|
||||||
|
*/
|
||||||
|
subscribeEvents(listener: (event: InspectorSourceEvent) => void): () => void {
|
||||||
|
this.eventListeners.add(listener)
|
||||||
|
return () => { this.eventListeners.delete(listener) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a typed control frame only to its still-active source generation.
|
||||||
|
* @param source - Expected active source generation.
|
||||||
|
* @param frame - Validated Worker-to-source frame.
|
||||||
|
* @returns Whether the generation was still active and accepted the send.
|
||||||
|
*/
|
||||||
|
send(source: InspectorSourceDescriptor, frame: WorkerToSourceFrame): boolean {
|
||||||
|
const state = this.sources.get(source.sourceId)
|
||||||
|
if (state === undefined || state.source.generation !== source.generation) return false
|
||||||
|
if (jsonByteLength(frame as unknown as InspectorJsonValue) > this.maxFrameBytes) {
|
||||||
|
throw new Error(`inspector protocol: Worker source frame exceeds ${String(this.maxFrameBytes)} bytes`)
|
||||||
|
}
|
||||||
|
state.connection.send(frame)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close every source and forget all state. */
|
||||||
|
close(): void {
|
||||||
|
for (const state of this.sources.values()) {
|
||||||
|
for (const consumer of this.consumers) consumer.close(state.source, 'inspector worker stopped')
|
||||||
|
this.emit({ type: 'closed', source: state.source, reason: 'inspector worker stopped' })
|
||||||
|
}
|
||||||
|
this.sources.clear()
|
||||||
|
this.notifyStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
private apply(connection: SourceConnection, frame: SourceToWorkerFrame): void {
|
||||||
|
if (frame.t === 'source/open') {
|
||||||
|
this.open(connection, frame.source, frame.topics)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const state = this.sources.get(frame.sourceId)
|
||||||
|
if (state === undefined || state.connection !== connection || state.source.generation !== frame.generation) {
|
||||||
|
throw new Error('inspector protocol: frame does not belong to the active source generation')
|
||||||
|
}
|
||||||
|
if (frame.t === 'source/close') {
|
||||||
|
this.sources.delete(frame.sourceId)
|
||||||
|
for (const consumer of this.consumers) consumer.close(state.source, 'source closed')
|
||||||
|
this.emit({ type: 'closed', source: state.source, reason: 'source closed' })
|
||||||
|
this.notifyStatus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (frame.t === 'client-runtime/response') {
|
||||||
|
if (state.source.kind !== 'client'
|
||||||
|
|| !state.source.capabilities.some(capability => capability.type === 'client-runtime')) {
|
||||||
|
throw new Error('inspector protocol: source did not declare Client Runtime')
|
||||||
|
}
|
||||||
|
this.emit({ type: 'client-runtime-response', source: state.source, frame })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (frame.t === 'client-console/event') {
|
||||||
|
if (state.source.kind !== 'client'
|
||||||
|
|| !state.source.capabilities.some(capability => capability.type === 'client-console')) {
|
||||||
|
throw new Error('inspector protocol: source did not declare Client Console')
|
||||||
|
}
|
||||||
|
this.emit({ type: 'client-console-event', source: state.source, frame })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (frame.t === 'client-sources/response') {
|
||||||
|
if (state.source.kind !== 'client'
|
||||||
|
|| !state.source.capabilities.some(capability => capability.type === 'client-sources')) {
|
||||||
|
throw new Error('inspector protocol: source did not declare Client Sources')
|
||||||
|
}
|
||||||
|
this.emit({ type: 'client-source-response', source: state.source, frame })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.assertTopics(state, frame.records)
|
||||||
|
if (frame.t === 'source/replace') {
|
||||||
|
state.expectedSequence = frame.nextSequence
|
||||||
|
for (const consumer of this.consumers) consumer.replace(
|
||||||
|
state.source,
|
||||||
|
frame.records.map((record, index) => ({ ...record, sequence: frame.nextSequence + index })),
|
||||||
|
)
|
||||||
|
this.count(state, frame.records)
|
||||||
|
this.notifyStatus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const gap = frame.firstSequence - state.expectedSequence
|
||||||
|
if (gap < 0 || gap !== frame.droppedBefore) {
|
||||||
|
connection.send({
|
||||||
|
v: INSPECTOR_PROTOCOL_VERSION,
|
||||||
|
t: 'source/resnapshot',
|
||||||
|
sourceId: state.source.sourceId,
|
||||||
|
generation: state.source.generation,
|
||||||
|
expectedSequence: state.expectedSequence,
|
||||||
|
reason: `expected sequence ${String(state.expectedSequence)}, received ${String(frame.firstSequence)}`,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.dropped += frame.droppedBefore
|
||||||
|
const records = frame.records.map((record, index) => ({ ...record, sequence: frame.firstSequence + index }))
|
||||||
|
state.expectedSequence = frame.firstSequence + frame.records.length
|
||||||
|
for (const consumer of this.consumers) consumer.append(state.source, records)
|
||||||
|
this.count(state, frame.records)
|
||||||
|
this.notifyStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
private open(connection: SourceConnection, source: InspectorSourceDescriptor, topics: readonly string[]): void {
|
||||||
|
if (source.kind !== connection.kind) throw new Error('inspector protocol: source kind does not match its carrier')
|
||||||
|
const accepted = new Set(topics)
|
||||||
|
const prior = this.sources.get(source.sourceId)
|
||||||
|
if (prior !== undefined) {
|
||||||
|
for (const consumer of this.consumers) consumer.close(prior.source, 'source generation replaced')
|
||||||
|
this.emit({ type: 'closed', source: prior.source, reason: 'source generation replaced' })
|
||||||
|
}
|
||||||
|
this.sources.set(source.sourceId, {
|
||||||
|
source,
|
||||||
|
topics: accepted,
|
||||||
|
connection,
|
||||||
|
expectedSequence: 1,
|
||||||
|
dropped: 0,
|
||||||
|
topicCounts: new Map(),
|
||||||
|
})
|
||||||
|
connection.send({
|
||||||
|
v: INSPECTOR_PROTOCOL_VERSION,
|
||||||
|
t: 'source/accepted',
|
||||||
|
sourceId: source.sourceId,
|
||||||
|
generation: source.generation,
|
||||||
|
})
|
||||||
|
this.emit({ type: 'opened', source })
|
||||||
|
this.notifyStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertTopics(state: SourceState, records: readonly InspectorRecordInput[]): void {
|
||||||
|
for (const record of records) {
|
||||||
|
if (!state.topics.has('*') && !state.topics.has(record.topic)) {
|
||||||
|
throw new Error(`inspector protocol: source did not declare topic ${JSON.stringify(record.topic)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private count(state: SourceState, records: readonly InspectorRecordInput[]): void {
|
||||||
|
for (const record of records) {
|
||||||
|
state.topicCounts.set(record.topic, (state.topicCounts.get(record.topic) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyStatus(): void {
|
||||||
|
for (const listener of [...this.statusListeners]) {
|
||||||
|
try {
|
||||||
|
listener()
|
||||||
|
} catch {
|
||||||
|
// A diagnostic observer is isolated from source admission and later observers.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(event: InspectorSourceEvent): void {
|
||||||
|
for (const listener of [...this.eventListeners]) {
|
||||||
|
try {
|
||||||
|
listener(event)
|
||||||
|
} catch {
|
||||||
|
// A protocol consumer is isolated from source admission and sibling consumers.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
/** Worker-owned routing between synthetic Client contexts and source generations. */
|
||||||
|
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import type {
|
||||||
|
ClientConsoleEventFrame,
|
||||||
|
ClientRuntimeCapability,
|
||||||
|
ClientRuntimeCommand,
|
||||||
|
ClientRuntimeError,
|
||||||
|
ClientRuntimeResponseFrame,
|
||||||
|
ClientRuntimeResult,
|
||||||
|
} from '../../shared/bridge/messages/runtime/index.ts'
|
||||||
|
import {
|
||||||
|
inspectorId,
|
||||||
|
type ClientRemoteObjectHandle,
|
||||||
|
type ClientRuntimeRequestId,
|
||||||
|
type ClientRuntimeSessionId,
|
||||||
|
} from '../../shared/bridge/ids.ts'
|
||||||
|
import { INSPECTOR_PROTOCOL_VERSION, type InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts'
|
||||||
|
import { sendClientSessionClosed } from './session.ts'
|
||||||
|
import type { InspectorSourceEvent, InspectorSourceRegistry } from './hub.ts'
|
||||||
|
import type { RuntimeConsoleBackendEvent } from '../../shared/cdp/index.ts'
|
||||||
|
|
||||||
|
/** One connected projection of a Client realm into a synthetic CDP execution context. */
|
||||||
|
export interface ClientRuntimeTarget {
|
||||||
|
readonly contextId: number
|
||||||
|
readonly uniqueContextId: string
|
||||||
|
readonly source: InspectorSourceDescriptor
|
||||||
|
readonly capability: ClientRuntimeCapability
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runtime target admission or removal. */
|
||||||
|
export type ClientRuntimeTargetEvent =
|
||||||
|
| { readonly type: 'opened'; readonly target: ClientRuntimeTarget }
|
||||||
|
| { readonly type: 'closed'; readonly target: ClientRuntimeTarget }
|
||||||
|
|
||||||
|
interface PendingRequest {
|
||||||
|
readonly target: ClientRuntimeTarget
|
||||||
|
readonly sessionId: ClientRuntimeSessionId
|
||||||
|
readonly op: ClientRuntimeCommand['op']
|
||||||
|
readonly resolve: (result: ClientRuntimeResult) => void
|
||||||
|
readonly reject: (error: Error) => void
|
||||||
|
readonly timer: ReturnType<typeof setTimeout>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConsoleSubscription {
|
||||||
|
readonly target: ClientRuntimeTarget
|
||||||
|
readonly sessionId: ClientRuntimeSessionId
|
||||||
|
readonly listener: (event: RuntimeConsoleBackendEvent<ClientRemoteObjectHandle>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Error returned deliberately by the Client Runtime executor. */
|
||||||
|
export class ClientRuntimeRemoteError extends Error {
|
||||||
|
constructor(readonly code: ClientRuntimeError['code'], message: string) {
|
||||||
|
super(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runtime context registry and correlated Worker-to-Client request owner. */
|
||||||
|
export class ClientRuntimeRouter {
|
||||||
|
private readonly targetsBySource = new Map<string, ClientRuntimeTarget>()
|
||||||
|
private readonly pending = new Map<ClientRuntimeRequestId, PendingRequest>()
|
||||||
|
private readonly consoleSubscriptions = new Set<ConsoleSubscription>()
|
||||||
|
private readonly listeners = new Set<(event: ClientRuntimeTargetEvent) => void>()
|
||||||
|
private readonly unsubscribeSources: () => void
|
||||||
|
private nextContextId = -1
|
||||||
|
private closed = false
|
||||||
|
|
||||||
|
constructor(private readonly sources: InspectorSourceRegistry, private readonly timeoutMs: number) {
|
||||||
|
this.unsubscribeSources = sources.subscribeEvents((event) => { this.receiveSourceEvent(event) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot all active Client execution contexts.
|
||||||
|
* @returns Active targets in admission order.
|
||||||
|
*/
|
||||||
|
targets(): ClientRuntimeTarget[] {
|
||||||
|
return [...this.targetsBySource.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the Client target for one active source generation.
|
||||||
|
* @param source - Source identity stored with a semantic node.
|
||||||
|
* @returns Its active Runtime target, when the generation still matches.
|
||||||
|
*/
|
||||||
|
bySource(source: InspectorSourceDescriptor): ClientRuntimeTarget | undefined {
|
||||||
|
const target = this.targetsBySource.get(source.sourceId)
|
||||||
|
return target?.source.generation === source.generation ? target : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to synthetic execution-context lifecycle.
|
||||||
|
* @param listener - Context lifecycle observer.
|
||||||
|
* @returns A disposer that removes the observer.
|
||||||
|
*/
|
||||||
|
subscribe(listener: (event: ClientRuntimeTargetEvent) => void): () => void {
|
||||||
|
this.listeners.add(listener)
|
||||||
|
return () => { this.listeners.delete(listener) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable Console events for one Client realm and DevTools session.
|
||||||
|
* @param target - Active Client realm.
|
||||||
|
* @param sessionId - DevTools Runtime session retaining event arguments.
|
||||||
|
* @param listener - Consumer of validated Client Console events.
|
||||||
|
* @returns A disposer that disables this Console session.
|
||||||
|
*/
|
||||||
|
subscribeConsole(
|
||||||
|
target: ClientRuntimeTarget,
|
||||||
|
sessionId: ClientRuntimeSessionId,
|
||||||
|
listener: (event: RuntimeConsoleBackendEvent<ClientRemoteObjectHandle>) => void,
|
||||||
|
): () => void {
|
||||||
|
const subscription: ConsoleSubscription = { target, sessionId, listener }
|
||||||
|
if (!this.sources.send(target.source, {
|
||||||
|
v: INSPECTOR_PROTOCOL_VERSION,
|
||||||
|
t: 'client-console/enable',
|
||||||
|
sourceId: target.source.sourceId,
|
||||||
|
generation: target.source.generation,
|
||||||
|
sessionId,
|
||||||
|
})) {
|
||||||
|
throw new Error('Client Console source disconnected before enable')
|
||||||
|
}
|
||||||
|
this.consoleSubscriptions.add(subscription)
|
||||||
|
return () => {
|
||||||
|
if (!this.consoleSubscriptions.delete(subscription)) return
|
||||||
|
try {
|
||||||
|
this.sources.send(target.source, {
|
||||||
|
v: INSPECTOR_PROTOCOL_VERSION,
|
||||||
|
t: 'client-console/disable',
|
||||||
|
sourceId: target.source.sourceId,
|
||||||
|
generation: target.source.generation,
|
||||||
|
sessionId,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Source removal also disables Console observation in the Client.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute one typed command in its currently active source generation.
|
||||||
|
* @param target - Active Client source and context.
|
||||||
|
* @param sessionId - Calling DevTools Runtime session.
|
||||||
|
* @param command - Validated Client Runtime operation.
|
||||||
|
* @returns The correlated result, or a rejection on timeout or disconnect.
|
||||||
|
*/
|
||||||
|
request(
|
||||||
|
target: ClientRuntimeTarget,
|
||||||
|
sessionId: ClientRuntimeSessionId,
|
||||||
|
command: ClientRuntimeCommand,
|
||||||
|
): Promise<ClientRuntimeResult> {
|
||||||
|
if (this.closed || this.targetsBySource.get(target.source.sourceId) !== target) {
|
||||||
|
return Promise.reject(new Error('Client execution context is no longer available'))
|
||||||
|
}
|
||||||
|
const requestId = inspectorId<'ClientRuntimeRequestId'>(randomUUID(), 'requestId')
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.pending.delete(requestId)
|
||||||
|
reject(new Error(`Client Runtime ${command.op} timed out after ${String(this.timeoutMs)}ms`))
|
||||||
|
}, this.timeoutMs)
|
||||||
|
timer.unref()
|
||||||
|
this.pending.set(requestId, { target, sessionId, op: command.op, resolve, reject, timer })
|
||||||
|
try {
|
||||||
|
const sent = this.sources.send(target.source, {
|
||||||
|
v: INSPECTOR_PROTOCOL_VERSION,
|
||||||
|
t: 'client-runtime/request',
|
||||||
|
sourceId: target.source.sourceId,
|
||||||
|
generation: target.source.generation,
|
||||||
|
sessionId,
|
||||||
|
requestId,
|
||||||
|
command,
|
||||||
|
})
|
||||||
|
if (!sent) this.rejectPending(requestId, new Error('Client execution context disconnected before dispatch'))
|
||||||
|
} catch (error) {
|
||||||
|
this.rejectPending(requestId, renderError(error))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close one realm-local Runtime session without notifying sibling Client realms.
|
||||||
|
* @param target - Client realm that owns the session.
|
||||||
|
* @param sessionId - Closing DevTools Runtime session.
|
||||||
|
*/
|
||||||
|
closeTargetSession(target: ClientRuntimeTarget, sessionId: ClientRuntimeSessionId): void {
|
||||||
|
for (const [requestId, pending] of this.pending) {
|
||||||
|
if (pending.target !== target || pending.sessionId !== sessionId) continue
|
||||||
|
this.rejectPending(requestId, new Error('DevTools Runtime session closed'))
|
||||||
|
}
|
||||||
|
for (const subscription of [...this.consoleSubscriptions]) {
|
||||||
|
if (subscription.target === target && subscription.sessionId === sessionId) {
|
||||||
|
this.consoleSubscriptions.delete(subscription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sendClientSessionClosed(this.sources, target.source, {
|
||||||
|
v: INSPECTOR_PROTOCOL_VERSION,
|
||||||
|
t: 'client-runtime/session-closed',
|
||||||
|
sourceId: target.source.sourceId,
|
||||||
|
generation: target.source.generation,
|
||||||
|
sessionId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop routing and reject every outstanding operation. */
|
||||||
|
close(): void {
|
||||||
|
if (this.closed) return
|
||||||
|
this.closed = true
|
||||||
|
this.unsubscribeSources()
|
||||||
|
for (const requestId of [...this.pending.keys()]) {
|
||||||
|
this.rejectPending(requestId, new Error('Client Runtime router closed'))
|
||||||
|
}
|
||||||
|
this.targetsBySource.clear()
|
||||||
|
this.consoleSubscriptions.clear()
|
||||||
|
this.listeners.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private receiveSourceEvent(event: InspectorSourceEvent): void {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'opened':
|
||||||
|
this.open(event.source)
|
||||||
|
return
|
||||||
|
case 'closed':
|
||||||
|
this.remove(event.source, event.reason)
|
||||||
|
return
|
||||||
|
case 'client-runtime-response':
|
||||||
|
this.settle(event.source, event.frame)
|
||||||
|
return
|
||||||
|
case 'client-console-event':
|
||||||
|
this.consoleEvent(event.source, event.frame)
|
||||||
|
return
|
||||||
|
case 'client-source-response':
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
assertNever(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private open(source: InspectorSourceDescriptor): void {
|
||||||
|
const capability = source.capabilities.find(
|
||||||
|
(candidate): candidate is ClientRuntimeCapability => candidate.type === 'client-runtime',
|
||||||
|
)
|
||||||
|
if (capability === undefined) return
|
||||||
|
const target: ClientRuntimeTarget = {
|
||||||
|
contextId: this.nextContextId--,
|
||||||
|
uniqueContextId: `dsh-client:${source.sourceId}:${source.generation}`,
|
||||||
|
source,
|
||||||
|
capability,
|
||||||
|
}
|
||||||
|
this.targetsBySource.set(source.sourceId, target)
|
||||||
|
this.emit({ type: 'opened', target })
|
||||||
|
}
|
||||||
|
|
||||||
|
private remove(source: InspectorSourceDescriptor, reason: string): void {
|
||||||
|
const target = this.targetsBySource.get(source.sourceId)
|
||||||
|
if (target === undefined || target.source.generation !== source.generation) return
|
||||||
|
this.targetsBySource.delete(source.sourceId)
|
||||||
|
for (const [requestId, pending] of this.pending) {
|
||||||
|
if (pending.target !== target) continue
|
||||||
|
this.rejectPending(requestId, new Error(`Client execution context closed: ${reason}`))
|
||||||
|
}
|
||||||
|
for (const subscription of [...this.consoleSubscriptions]) {
|
||||||
|
if (subscription.target === target) this.consoleSubscriptions.delete(subscription)
|
||||||
|
}
|
||||||
|
this.emit({ type: 'closed', target })
|
||||||
|
}
|
||||||
|
|
||||||
|
private consoleEvent(source: InspectorSourceDescriptor, frame: ClientConsoleEventFrame): void {
|
||||||
|
const target = this.targetsBySource.get(source.sourceId)
|
||||||
|
if (target === undefined || target.source.generation !== source.generation) return
|
||||||
|
for (const subscription of [...this.consoleSubscriptions]) {
|
||||||
|
if (subscription.target !== target || subscription.sessionId !== frame.sessionId) continue
|
||||||
|
try {
|
||||||
|
subscription.listener(frame.event)
|
||||||
|
} catch {
|
||||||
|
// One DevTools Console session cannot disrupt sibling sessions.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private settle(source: InspectorSourceDescriptor, frame: ClientRuntimeResponseFrame): void {
|
||||||
|
const pending = this.pending.get(frame.requestId)
|
||||||
|
if (pending === undefined) return
|
||||||
|
if (pending.target.source.sourceId !== source.sourceId
|
||||||
|
|| pending.target.source.generation !== source.generation
|
||||||
|
|| pending.sessionId !== frame.sessionId) {
|
||||||
|
this.rejectPending(frame.requestId, new Error('Client Runtime response correlation mismatch'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!frame.outcome.ok) {
|
||||||
|
this.rejectPending(frame.requestId, new ClientRuntimeRemoteError(frame.outcome.error.code, frame.outcome.error.message))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (frame.outcome.result.op !== pending.op) {
|
||||||
|
this.rejectPending(frame.requestId, new Error(
|
||||||
|
`Client Runtime response op ${frame.outcome.result.op} does not match ${pending.op}`,
|
||||||
|
))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearTimeout(pending.timer)
|
||||||
|
this.pending.delete(frame.requestId)
|
||||||
|
pending.resolve(frame.outcome.result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private rejectPending(requestId: ClientRuntimeRequestId, error: Error): void {
|
||||||
|
const pending = this.pending.get(requestId)
|
||||||
|
if (pending === undefined) return
|
||||||
|
clearTimeout(pending.timer)
|
||||||
|
this.pending.delete(requestId)
|
||||||
|
pending.reject(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(event: ClientRuntimeTargetEvent): void {
|
||||||
|
for (const listener of [...this.listeners]) {
|
||||||
|
try {
|
||||||
|
listener(event)
|
||||||
|
} catch {
|
||||||
|
// One CDP session cannot disrupt context delivery to another session.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderError(error: unknown): Error {
|
||||||
|
return error instanceof Error ? error : new Error(String(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNever(value: never): never {
|
||||||
|
throw new Error(`Unexpected source event: ${JSON.stringify(value)}`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/** Shared cleanup delivery for Worker-owned Client sessions. */
|
||||||
|
|
||||||
|
import type { ClientRuntimeSessionClosedFrame } from '../../shared/bridge/messages/runtime/index.ts'
|
||||||
|
import type { ClientSourceSessionClosedFrame } from '../../shared/bridge/messages/sources/index.ts'
|
||||||
|
import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts'
|
||||||
|
import type { InspectorSourceRegistry } from './hub.ts'
|
||||||
|
|
||||||
|
type ClientSessionClosedFrame = ClientRuntimeSessionClosedFrame | ClientSourceSessionClosedFrame
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send cleanup to an active Client generation when its transport is still usable.
|
||||||
|
* @param sources - Worker source registry owning the transport.
|
||||||
|
* @param source - Generation whose session closed.
|
||||||
|
* @param frame - Typed Runtime or source-catalog cleanup frame.
|
||||||
|
*/
|
||||||
|
export function sendClientSessionClosed(
|
||||||
|
sources: InspectorSourceRegistry,
|
||||||
|
source: InspectorSourceDescriptor,
|
||||||
|
frame: ClientSessionClosedFrame,
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
sources.send(source, frame)
|
||||||
|
} catch {
|
||||||
|
// Source removal already invalidates every session owned by this generation.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
/** Worker-owned request routing for Client read-only source catalogs. */
|
||||||
|
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import type {
|
||||||
|
ClientSourceCommand,
|
||||||
|
ClientSourceError,
|
||||||
|
ClientSourceResponseFrame,
|
||||||
|
ClientSourceResult,
|
||||||
|
} from '../../shared/bridge/messages/sources/index.ts'
|
||||||
|
import {
|
||||||
|
inspectorId,
|
||||||
|
type ClientSourceRequestId,
|
||||||
|
type ClientSourceSessionId,
|
||||||
|
} from '../../shared/bridge/ids.ts'
|
||||||
|
import { INSPECTOR_PROTOCOL_VERSION, type InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts'
|
||||||
|
import { sendClientSessionClosed } from './session.ts'
|
||||||
|
import type { InspectorSourceEvent, InspectorSourceRegistry } from './hub.ts'
|
||||||
|
|
||||||
|
interface PendingSourceRequest {
|
||||||
|
readonly source: InspectorSourceDescriptor
|
||||||
|
readonly sessionId: ClientSourceSessionId
|
||||||
|
readonly command: ClientSourceCommand
|
||||||
|
readonly resolve: (result: ClientSourceResult) => void
|
||||||
|
readonly reject: (error: Error) => void
|
||||||
|
readonly timer: ReturnType<typeof setTimeout>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deliberate error returned by the Client source catalog. */
|
||||||
|
export class ClientSourceRemoteError extends Error {
|
||||||
|
constructor(readonly code: ClientSourceError['code'], message: string) {
|
||||||
|
super(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Correlates bounded source requests with one active Client source generation. */
|
||||||
|
export class ClientSourceRouter {
|
||||||
|
/** Maximum decoded bytes requested in one source-content response. */
|
||||||
|
readonly chunkBytes: number
|
||||||
|
private readonly pending = new Map<ClientSourceRequestId, PendingSourceRequest>()
|
||||||
|
private readonly unsubscribeSources: () => void
|
||||||
|
private closed = false
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly sources: InspectorSourceRegistry,
|
||||||
|
private readonly timeoutMs: number,
|
||||||
|
readonly maxContentBytes: number,
|
||||||
|
maxFrameBytes: number,
|
||||||
|
) {
|
||||||
|
this.chunkBytes = Math.max(1, Math.floor((maxFrameBytes - 4_096) * 3 / 4))
|
||||||
|
this.unsubscribeSources = sources.subscribeEvents((event) => { this.receiveSourceEvent(event) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute one operation against an active Client source generation.
|
||||||
|
* @param source - Client source that owns the script catalog.
|
||||||
|
* @param sessionId - DevTools connection-local source session.
|
||||||
|
* @param command - Validated read-only source command.
|
||||||
|
* @returns The correlated result.
|
||||||
|
*/
|
||||||
|
request(
|
||||||
|
source: InspectorSourceDescriptor,
|
||||||
|
sessionId: ClientSourceSessionId,
|
||||||
|
command: ClientSourceCommand,
|
||||||
|
): Promise<ClientSourceResult> {
|
||||||
|
if (this.closed) return Promise.reject(new Error('Client source router is closed'))
|
||||||
|
const requestId = inspectorId<'ClientSourceRequestId'>(randomUUID(), 'requestId')
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.pending.delete(requestId)
|
||||||
|
reject(new Error(`Client source ${command.op} timed out after ${String(this.timeoutMs)}ms`))
|
||||||
|
}, this.timeoutMs)
|
||||||
|
timer.unref()
|
||||||
|
this.pending.set(requestId, { source, sessionId, command, resolve, reject, timer })
|
||||||
|
try {
|
||||||
|
const sent = this.sources.send(source, {
|
||||||
|
v: INSPECTOR_PROTOCOL_VERSION,
|
||||||
|
t: 'client-sources/request',
|
||||||
|
sourceId: source.sourceId,
|
||||||
|
generation: source.generation,
|
||||||
|
sessionId,
|
||||||
|
requestId,
|
||||||
|
command,
|
||||||
|
})
|
||||||
|
if (!sent) this.rejectPending(requestId, new Error('Client source disconnected before dispatch'))
|
||||||
|
} catch (error) {
|
||||||
|
this.rejectPending(requestId, renderError(error))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject pending operations and notify one Client source session that it closed.
|
||||||
|
* @param source - Source generation owning the session.
|
||||||
|
* @param sessionId - Closing source session.
|
||||||
|
*/
|
||||||
|
closeSession(source: InspectorSourceDescriptor, sessionId: ClientSourceSessionId): void {
|
||||||
|
for (const [requestId, pending] of this.pending) {
|
||||||
|
if (pending.source.sourceId !== source.sourceId
|
||||||
|
|| pending.source.generation !== source.generation
|
||||||
|
|| pending.sessionId !== sessionId) continue
|
||||||
|
this.rejectPending(requestId, new Error('DevTools source session closed'))
|
||||||
|
}
|
||||||
|
sendClientSessionClosed(this.sources, source, {
|
||||||
|
v: INSPECTOR_PROTOCOL_VERSION,
|
||||||
|
t: 'client-sources/session-closed',
|
||||||
|
sourceId: source.sourceId,
|
||||||
|
generation: source.generation,
|
||||||
|
sessionId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop routing and reject every outstanding source operation. */
|
||||||
|
close(): void {
|
||||||
|
if (this.closed) return
|
||||||
|
this.closed = true
|
||||||
|
this.unsubscribeSources()
|
||||||
|
for (const requestId of [...this.pending.keys()]) {
|
||||||
|
this.rejectPending(requestId, new Error('Client source router closed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private receiveSourceEvent(event: InspectorSourceEvent): void {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'closed':
|
||||||
|
for (const [requestId, pending] of this.pending) {
|
||||||
|
if (pending.source.sourceId === event.source.sourceId
|
||||||
|
&& pending.source.generation === event.source.generation) {
|
||||||
|
this.rejectPending(requestId, new Error(`Client source closed: ${event.reason}`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case 'client-source-response':
|
||||||
|
this.settle(event.source, event.frame)
|
||||||
|
return
|
||||||
|
case 'opened':
|
||||||
|
case 'client-runtime-response':
|
||||||
|
case 'client-console-event':
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
assertNever(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private settle(source: InspectorSourceDescriptor, frame: ClientSourceResponseFrame): void {
|
||||||
|
const pending = this.pending.get(frame.requestId)
|
||||||
|
if (pending === undefined) return
|
||||||
|
if (pending.source.sourceId !== source.sourceId
|
||||||
|
|| pending.source.generation !== source.generation
|
||||||
|
|| pending.sessionId !== frame.sessionId) {
|
||||||
|
this.rejectPending(frame.requestId, new Error('Client source response correlation mismatch'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!frame.outcome.ok) {
|
||||||
|
this.rejectPending(
|
||||||
|
frame.requestId,
|
||||||
|
new ClientSourceRemoteError(frame.outcome.error.code, frame.outcome.error.message),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!matchesCommand(pending.command, frame.outcome.result)) {
|
||||||
|
this.rejectPending(frame.requestId, new Error('Client source response does not match its request'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearTimeout(pending.timer)
|
||||||
|
this.pending.delete(frame.requestId)
|
||||||
|
pending.resolve(frame.outcome.result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private rejectPending(requestId: ClientSourceRequestId, error: Error): void {
|
||||||
|
const pending = this.pending.get(requestId)
|
||||||
|
if (pending === undefined) return
|
||||||
|
clearTimeout(pending.timer)
|
||||||
|
this.pending.delete(requestId)
|
||||||
|
pending.reject(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesCommand(command: ClientSourceCommand, result: ClientSourceResult): boolean {
|
||||||
|
if (command.op !== result.op) return false
|
||||||
|
if (command.op === 'list-scripts' || result.op === 'list-scripts') return true
|
||||||
|
return result.scriptKey === command.scriptKey
|
||||||
|
&& result.content === command.content
|
||||||
|
&& (!result.available || result.offset === command.offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderError(error: unknown): Error {
|
||||||
|
return error instanceof Error ? error : new Error(String(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNever(value: never): never {
|
||||||
|
throw new Error(`Unexpected source event: ${JSON.stringify(value)}`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/** Validation for CDP Debugger requests handled by the shared domain. */
|
||||||
|
|
||||||
|
import type { RuntimeCallFrameEvaluationRequest } from '../../../../shared/cdp/index.ts'
|
||||||
|
import { exactKeys, optionalBoolean, optionalString } from '../../../../shared/validation.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Debugger.evaluateOnCallFrame without silently accepting unsupported options.
|
||||||
|
* @param params - Untrusted CDP parameters.
|
||||||
|
* @returns The common call-frame evaluation request.
|
||||||
|
*/
|
||||||
|
export function parseCallFrameEvaluation(
|
||||||
|
params: Readonly<Record<string, unknown>>,
|
||||||
|
): RuntimeCallFrameEvaluationRequest {
|
||||||
|
exactKeys(params, [
|
||||||
|
'callFrameId', 'expression', 'objectGroup', 'includeCommandLineAPI', 'silent', 'returnByValue',
|
||||||
|
'generatePreview', 'throwOnSideEffect', 'timeout',
|
||||||
|
], 'Debugger.evaluateOnCallFrame parameters')
|
||||||
|
if (typeof params.callFrameId !== 'string' || typeof params.expression !== 'string') {
|
||||||
|
throw new Error('Debugger.evaluateOnCallFrame requires callFrameId and expression')
|
||||||
|
}
|
||||||
|
if (params.timeout !== undefined
|
||||||
|
&& (typeof params.timeout !== 'number' || !Number.isFinite(params.timeout) || params.timeout < 0)) {
|
||||||
|
throw new Error('Debugger.evaluateOnCallFrame timeout must be a non-negative number')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
callFrameId: params.callFrameId,
|
||||||
|
expression: params.expression,
|
||||||
|
...optionalString(params, 'objectGroup'),
|
||||||
|
...optionalBoolean(params, 'includeCommandLineAPI'),
|
||||||
|
...optionalBoolean(params, 'silent'),
|
||||||
|
...optionalBoolean(params, 'returnByValue'),
|
||||||
|
...optionalBoolean(params, 'generatePreview'),
|
||||||
|
...optionalBoolean(params, 'throwOnSideEffect'),
|
||||||
|
...(params.timeout === undefined ? {} : { timeoutMs: params.timeout }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a ScriptId carried directly or by a Debugger location parameter.
|
||||||
|
* @param params - Parsed CDP parameter record.
|
||||||
|
* @returns The targeted script id when the request names one.
|
||||||
|
*/
|
||||||
|
export function requestScriptId(params: Readonly<Record<string, unknown>>): string | undefined {
|
||||||
|
if (typeof params.scriptId === 'string') return params.scriptId
|
||||||
|
for (const key of ['location', 'start', 'end'] as const) {
|
||||||
|
const value = params[key]
|
||||||
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) continue
|
||||||
|
const scriptId = (value as Readonly<Record<string, unknown>>).scriptId
|
||||||
|
if (typeof scriptId === 'string') return scriptId
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/** Shared Debugger domain exports. */
|
||||||
|
|
||||||
|
export * from './cdp-params.ts'
|
||||||
|
export * from './projector.ts'
|
||||||
|
export * from './script-registry.ts'
|
||||||
|
export * from './session.ts'
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/** CDP projection for realm-neutral scripts and debugger events. */
|
||||||
|
|
||||||
|
import type { RuntimeDebuggerEvent, RuntimeDebuggerLocation, RuntimeScript, RuntimeStackTrace } from '../../../../shared/cdp/index.ts'
|
||||||
|
import type { RuntimeBackendObjectHandle } from '../../../../shared/cdp/ids.ts'
|
||||||
|
import type { CdpNotification } from '../../protocol.ts'
|
||||||
|
import type { InspectorRealmSession } from '../../../inspection/realm.ts'
|
||||||
|
import type { RuntimeDomainSession } from '../runtime/index.ts'
|
||||||
|
import { cdpScriptId } from './script-registry.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project one common script descriptor to Debugger.scriptParsed.
|
||||||
|
* @param realm - Realm session that owns the script.
|
||||||
|
* @param script - Realm-neutral script descriptor.
|
||||||
|
* @returns A CDP scriptParsed notification.
|
||||||
|
*/
|
||||||
|
export function scriptParsedEvent(realm: InspectorRealmSession, script: RuntimeScript): CdpNotification {
|
||||||
|
return {
|
||||||
|
method: 'Debugger.scriptParsed',
|
||||||
|
params: {
|
||||||
|
scriptId: cdpScriptId(script.scriptKey),
|
||||||
|
url: script.url,
|
||||||
|
startLine: script.startLine,
|
||||||
|
startColumn: script.startColumn,
|
||||||
|
endLine: script.endLine,
|
||||||
|
endColumn: script.endColumn,
|
||||||
|
executionContextId: script.executionContextId
|
||||||
|
?? (realm.context.kind === 'synthetic' ? realm.context.id : 0),
|
||||||
|
hash: script.hash,
|
||||||
|
buildId: script.buildId ?? '',
|
||||||
|
...(script.sourceMapUrl === undefined ? {} : { sourceMapURL: script.sourceMapUrl }),
|
||||||
|
...(script.isModule === undefined ? {} : { isModule: script.isModule }),
|
||||||
|
...(script.length === undefined ? {} : { length: script.length }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project one common debugger event and all nested Runtime objects to CDP.
|
||||||
|
* @param realm - Realm session that emitted the event.
|
||||||
|
* @param event - Realm-neutral debugger event.
|
||||||
|
* @param runtime - Connection-local Runtime object projector.
|
||||||
|
* @returns The corresponding CDP notification.
|
||||||
|
*/
|
||||||
|
export function debuggerEvent(
|
||||||
|
realm: InspectorRealmSession,
|
||||||
|
event: RuntimeDebuggerEvent<RuntimeBackendObjectHandle>,
|
||||||
|
runtime: RuntimeDomainSession,
|
||||||
|
): CdpNotification {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'paused':
|
||||||
|
return {
|
||||||
|
method: 'Debugger.paused',
|
||||||
|
params: {
|
||||||
|
callFrames: event.callFrames.map(frame => ({
|
||||||
|
callFrameId: frame.callFrameId,
|
||||||
|
functionName: frame.functionName,
|
||||||
|
...(frame.functionLocation === undefined ? {} : { functionLocation: location(frame.functionLocation) }),
|
||||||
|
location: location(frame.location),
|
||||||
|
url: frame.url,
|
||||||
|
scopeChain: frame.scopeChain.map(scope => ({
|
||||||
|
type: scope.type,
|
||||||
|
object: runtime.projectRemoteObject(realm, scope.object, 'backtrace'),
|
||||||
|
...(scope.name === undefined ? {} : { name: scope.name }),
|
||||||
|
...(scope.startLocation === undefined ? {} : { startLocation: location(scope.startLocation) }),
|
||||||
|
...(scope.endLocation === undefined ? {} : { endLocation: location(scope.endLocation) }),
|
||||||
|
})),
|
||||||
|
this: runtime.projectRemoteObject(realm, frame.thisObject, 'backtrace'),
|
||||||
|
...(frame.returnValue === undefined
|
||||||
|
? {}
|
||||||
|
: { returnValue: runtime.projectRemoteObject(realm, frame.returnValue, 'backtrace') }),
|
||||||
|
})),
|
||||||
|
reason: event.reason,
|
||||||
|
...(event.data === undefined ? {} : { data: event.data }),
|
||||||
|
...(event.hitBreakpoints === undefined ? {} : { hitBreakpoints: event.hitBreakpoints }),
|
||||||
|
...(event.asyncStackTrace === undefined ? {} : { asyncStackTrace: stackTrace(event.asyncStackTrace) }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
case 'resumed':
|
||||||
|
return { method: 'Debugger.resumed', params: {} }
|
||||||
|
case 'breakpoint-resolved':
|
||||||
|
return {
|
||||||
|
method: 'Debugger.breakpointResolved',
|
||||||
|
params: { breakpointId: event.breakpointId, location: location(event.location) },
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return assertNever(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function location(value: RuntimeDebuggerLocation): Readonly<Record<string, unknown>> {
|
||||||
|
return {
|
||||||
|
scriptId: cdpScriptId(value.scriptKey),
|
||||||
|
lineNumber: value.lineNumber,
|
||||||
|
...(value.columnNumber === undefined ? {} : { columnNumber: value.columnNumber }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stackTrace(value: RuntimeStackTrace): Readonly<Record<string, unknown>> {
|
||||||
|
return {
|
||||||
|
...(value.description === undefined ? {} : { description: value.description }),
|
||||||
|
callFrames: value.callFrames.map(frame => ({
|
||||||
|
functionName: frame.functionName,
|
||||||
|
scriptId: frame.scriptKey === undefined ? '0' : cdpScriptId(frame.scriptKey),
|
||||||
|
url: frame.url,
|
||||||
|
lineNumber: frame.lineNumber,
|
||||||
|
columnNumber: frame.columnNumber,
|
||||||
|
})),
|
||||||
|
...(value.parent === undefined ? {} : { parent: stackTrace(value.parent) }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNever(value: never): never {
|
||||||
|
throw new Error(`Unexpected debugger event: ${JSON.stringify(value)}`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/** Connection-local routing from CDP ScriptId values to realm source backends. */
|
||||||
|
|
||||||
|
import type { RuntimeScriptKey } from '../../../../shared/cdp/ids.ts'
|
||||||
|
import type { RuntimeScript } from '../../../../shared/cdp/index.ts'
|
||||||
|
import type { SourceBackend } from '../../../../shared/cdp/realm.ts'
|
||||||
|
import type { InspectorRealmSession } from '../../../inspection/realm.ts'
|
||||||
|
import { cdpStringId, type CdpScriptId } from '../../ids.ts'
|
||||||
|
|
||||||
|
/** One script and the realm source backend that owns its content. */
|
||||||
|
export interface DebuggerScriptRoute {
|
||||||
|
readonly realm: InspectorRealmSession
|
||||||
|
readonly source: SourceBackend
|
||||||
|
readonly script: RuntimeScript
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tracks active and retired scripts without exposing source transport ids. */
|
||||||
|
export class DebuggerScriptRegistry {
|
||||||
|
private readonly routes = new Map<CdpScriptId, DebuggerScriptRoute>()
|
||||||
|
private readonly retiredUnsupported = new Set<CdpScriptId>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register one realm script under its globally unique Runtime script key.
|
||||||
|
* @param route - Script descriptor and owning realm session.
|
||||||
|
* @returns The CDP ScriptId and whether this is its first announcement.
|
||||||
|
*/
|
||||||
|
register(route: DebuggerScriptRoute): { readonly scriptId: CdpScriptId; readonly fresh: boolean } {
|
||||||
|
const scriptId = cdpScriptId(route.script.scriptKey)
|
||||||
|
const current = this.routes.get(scriptId)
|
||||||
|
if (current !== undefined && current.realm !== route.realm) {
|
||||||
|
throw new Error(`Inspector realms produced the same script key ${scriptId}`)
|
||||||
|
}
|
||||||
|
this.routes.set(scriptId, route)
|
||||||
|
return { scriptId, fresh: current === undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an active CDP ScriptId.
|
||||||
|
* @param scriptId - Connection-visible script id.
|
||||||
|
* @returns The active route when the script remains connected.
|
||||||
|
*/
|
||||||
|
resolve(scriptId: string): DebuggerScriptRoute | undefined {
|
||||||
|
return this.routes.get(cdpStringId<'CdpScriptId'>(scriptId, 'scriptId'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a script by its exact URL.
|
||||||
|
* @param url - Script URL from a CDP request.
|
||||||
|
* @returns The active route when one script has that URL.
|
||||||
|
*/
|
||||||
|
byUrl(url: string): DebuggerScriptRoute | undefined {
|
||||||
|
for (const route of this.routes.values()) {
|
||||||
|
if (route.script.url === url) return route
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a script by its exact content hash.
|
||||||
|
* @param hash - Script hash from a breakpoint request.
|
||||||
|
* @returns The active route when one script has that hash.
|
||||||
|
*/
|
||||||
|
byHash(hash: string): DebuggerScriptRoute | undefined {
|
||||||
|
for (const route of this.routes.values()) {
|
||||||
|
if (route.script.hash === hash) return route
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the first script whose URL matches a breakpoint regular expression.
|
||||||
|
* @param pattern - JavaScript regular-expression source accepted by CDP.
|
||||||
|
* @returns The first matching active route.
|
||||||
|
*/
|
||||||
|
byUrlPattern(pattern: string): DebuggerScriptRoute | undefined {
|
||||||
|
const expression = new RegExp(pattern, 'u')
|
||||||
|
for (const route of this.routes.values()) {
|
||||||
|
if (expression.test(route.script.url)) return route
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test whether a disconnected script belonged to a realm without active debugging.
|
||||||
|
* @param scriptId - Script id from a later CDP request.
|
||||||
|
* @returns Whether the id must still fail as an unsupported Client script.
|
||||||
|
*/
|
||||||
|
wasUnsupported(scriptId: string): boolean {
|
||||||
|
return this.retiredUnsupported.has(cdpStringId<'CdpScriptId'>(scriptId, 'scriptId'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forget scripts for one closed realm while retaining their unsupported identity.
|
||||||
|
* @param realm - Realm session being removed.
|
||||||
|
*/
|
||||||
|
removeRealm(realm: InspectorRealmSession): void {
|
||||||
|
for (const [scriptId, route] of this.routes) {
|
||||||
|
if (route.realm !== realm) continue
|
||||||
|
this.routes.delete(scriptId)
|
||||||
|
if (realm.debugger.state === 'unsupported') this.retiredUnsupported.add(scriptId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forget all active and retired script routes. */
|
||||||
|
clear(): void {
|
||||||
|
this.routes.clear()
|
||||||
|
this.retiredUnsupported.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preserve a branded script key as its CDP wire identifier.
|
||||||
|
* @param scriptKey - Realm-wide Runtime script key.
|
||||||
|
* @returns The corresponding CDP ScriptId text.
|
||||||
|
*/
|
||||||
|
export function cdpScriptId(scriptKey: RuntimeScriptKey): CdpScriptId {
|
||||||
|
return cdpStringId<'CdpScriptId'>(scriptKey, 'scriptId')
|
||||||
|
}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
/** Per-DevTools Debugger and source routing across Host and Client realms. */
|
||||||
|
|
||||||
|
import { respondToCdpRequest, sendCdpFailure, type CdpRequest, type CdpTransport } from '../../protocol.ts'
|
||||||
|
import type {
|
||||||
|
DebuggerBackend,
|
||||||
|
NativeDomainBackend,
|
||||||
|
SourceBackend,
|
||||||
|
} from '../../../../shared/cdp/realm.ts'
|
||||||
|
import type { RuntimeBackendObjectHandle } from '../../../../shared/cdp/ids.ts'
|
||||||
|
import type { InspectorRealmSession } from '../../../inspection/realm.ts'
|
||||||
|
import type { InspectorRealmSessionEvent, InspectorRealmSessionSet } from '../../realm-sessions.ts'
|
||||||
|
import type {
|
||||||
|
RuntimeDebuggerEnableRequest,
|
||||||
|
RuntimeDebuggerEvent,
|
||||||
|
RuntimeScript,
|
||||||
|
} from '../../../../shared/cdp/index.ts'
|
||||||
|
import { exactKeys, optionalBoolean } from '../../../../shared/validation.ts'
|
||||||
|
import type { RuntimeDomainSession } from '../runtime/index.ts'
|
||||||
|
import { parseCallFrameEvaluation, requestScriptId } from './cdp-params.ts'
|
||||||
|
import { debuggerEvent, scriptParsedEvent } from './projector.ts'
|
||||||
|
import { DebuggerScriptRegistry } from './script-registry.ts'
|
||||||
|
|
||||||
|
/** Owns Debugger lifecycle, shared script projection, and Host-native fallback. */
|
||||||
|
export class DebuggerDomainSession {
|
||||||
|
private readonly scripts = new DebuggerScriptRegistry()
|
||||||
|
private readonly sourceDisposers = new Map<string, () => void>()
|
||||||
|
private readonly debuggerDisposers = new Map<string, () => void>()
|
||||||
|
private readonly callFrameRealms = new Map<string, InspectorRealmSession>()
|
||||||
|
private readonly unsubscribeRealms: () => void
|
||||||
|
private readonly native: NativeDomainBackend
|
||||||
|
private debuggerEnableRequest: RuntimeDebuggerEnableRequest = {}
|
||||||
|
private enabled = false
|
||||||
|
private closed = false
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly transport: CdpTransport,
|
||||||
|
private readonly realms: InspectorRealmSessionSet,
|
||||||
|
private readonly runtime: RuntimeDomainSession,
|
||||||
|
) {
|
||||||
|
const native = realms.all()
|
||||||
|
.map(realm => realm.nativeDomains)
|
||||||
|
.find(capability => capability.state === 'supported')
|
||||||
|
if (native === undefined) throw new Error('Inspector has no native Host debugger transport')
|
||||||
|
this.native = native.backend
|
||||||
|
this.unsubscribeRealms = realms.subscribe((event) => { this.receiveRealm(event) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle one Debugger request, including Client read-only source operations.
|
||||||
|
* @param request - Parsed CDP request.
|
||||||
|
* @returns Whether the method belongs to the Debugger domain.
|
||||||
|
*/
|
||||||
|
handle(request: CdpRequest): boolean {
|
||||||
|
if (!request.method.startsWith('Debugger.')) return false
|
||||||
|
switch (request.method) {
|
||||||
|
case 'Debugger.enable':
|
||||||
|
this.respond(request, () => this.enable(request.params))
|
||||||
|
return true
|
||||||
|
case 'Debugger.disable':
|
||||||
|
exactKeys(request.params, [], 'Debugger.disable parameters')
|
||||||
|
this.respond(request, () => this.disable())
|
||||||
|
return true
|
||||||
|
case 'Debugger.getScriptSource':
|
||||||
|
this.respond(request, () => this.getScriptSource(request.params))
|
||||||
|
return true
|
||||||
|
case 'Debugger.searchInContent':
|
||||||
|
this.respond(request, () => this.searchInContent(request.params))
|
||||||
|
return true
|
||||||
|
case 'Debugger.evaluateOnCallFrame':
|
||||||
|
this.respond(request, () => this.evaluateOnCallFrame(request.params))
|
||||||
|
return true
|
||||||
|
case 'Debugger.pause':
|
||||||
|
exactKeys(request.params, [], 'Debugger.pause parameters')
|
||||||
|
this.respond(request, () => this.pause())
|
||||||
|
return true
|
||||||
|
case 'Debugger.resume':
|
||||||
|
this.respond(request, () => this.resume(request.params))
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
this.forwardNative(request)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Release source and debugger subscriptions. */
|
||||||
|
close(): void {
|
||||||
|
if (this.closed) return
|
||||||
|
this.closed = true
|
||||||
|
this.unsubscribeRealms()
|
||||||
|
this.detachCapabilities()
|
||||||
|
this.callFrameRealms.clear()
|
||||||
|
this.scripts.clear()
|
||||||
|
this.runtime.releaseProjectedGroup('backtrace')
|
||||||
|
}
|
||||||
|
|
||||||
|
private async enable(params: Readonly<Record<string, unknown>>): Promise<Readonly<Record<string, unknown>>> {
|
||||||
|
exactKeys(params, ['maxScriptsCacheSize'], 'Debugger.enable parameters')
|
||||||
|
if (this.enabled) return {}
|
||||||
|
const maxScriptsCacheSize = params.maxScriptsCacheSize
|
||||||
|
if (maxScriptsCacheSize !== undefined
|
||||||
|
&& (typeof maxScriptsCacheSize !== 'number' || !Number.isFinite(maxScriptsCacheSize) || maxScriptsCacheSize < 0)) {
|
||||||
|
throw new Error('Debugger.enable maxScriptsCacheSize must be a non-negative number')
|
||||||
|
}
|
||||||
|
const enableRequest = maxScriptsCacheSize === undefined ? {} : { maxScriptsCacheSize }
|
||||||
|
this.debuggerEnableRequest = enableRequest
|
||||||
|
this.enabled = true
|
||||||
|
try {
|
||||||
|
for (const realm of this.realms.all()) this.attachCapabilities(realm)
|
||||||
|
const results = await Promise.all(this.realms.all().map(async realm =>
|
||||||
|
realm.debugger.state === 'supported' ? realm.debugger.backend.enable(enableRequest) : {}))
|
||||||
|
await Promise.all(this.realms.all().map(async realm => this.publishCatalog(realm)))
|
||||||
|
return mergeResults(results)
|
||||||
|
} catch (error) {
|
||||||
|
this.enabled = false
|
||||||
|
this.debuggerEnableRequest = {}
|
||||||
|
this.detachCapabilities()
|
||||||
|
this.scripts.clear()
|
||||||
|
await Promise.allSettled(this.realms.all().map(async (realm) => {
|
||||||
|
if (realm.debugger.state === 'supported') await realm.debugger.backend.disable()
|
||||||
|
}))
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async disable(): Promise<Readonly<Record<string, unknown>>> {
|
||||||
|
this.enabled = false
|
||||||
|
this.debuggerEnableRequest = {}
|
||||||
|
this.detachCapabilities()
|
||||||
|
this.callFrameRealms.clear()
|
||||||
|
this.scripts.clear()
|
||||||
|
this.runtime.releaseProjectedGroup('backtrace')
|
||||||
|
const results = await Promise.all(this.realms.all().map(async realm =>
|
||||||
|
realm.debugger.state === 'supported' ? realm.debugger.backend.disable() : {}))
|
||||||
|
return mergeResults(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getScriptSource(params: Readonly<Record<string, unknown>>): Promise<object> {
|
||||||
|
exactKeys(params, ['scriptId'], 'Debugger.getScriptSource parameters')
|
||||||
|
if (typeof params.scriptId !== 'string') throw new Error('Debugger.getScriptSource requires scriptId')
|
||||||
|
const route = this.scripts.resolve(params.scriptId)
|
||||||
|
if (route !== undefined) return { scriptSource: await route.source.getScriptSource(route.script.scriptKey) }
|
||||||
|
if (this.scripts.wasUnsupported(params.scriptId) || params.scriptId.startsWith('client:')) {
|
||||||
|
throw new Error('Client script is no longer available')
|
||||||
|
}
|
||||||
|
return this.native.request('Debugger.getScriptSource', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async searchInContent(params: Readonly<Record<string, unknown>>): Promise<object> {
|
||||||
|
exactKeys(params, ['scriptId', 'query', 'caseSensitive', 'isRegex'], 'Debugger.searchInContent parameters')
|
||||||
|
if (typeof params.scriptId !== 'string' || typeof params.query !== 'string') {
|
||||||
|
throw new Error('Debugger.searchInContent requires scriptId and query')
|
||||||
|
}
|
||||||
|
if (params.caseSensitive !== undefined && typeof params.caseSensitive !== 'boolean') {
|
||||||
|
throw new Error('Debugger.searchInContent caseSensitive must be a boolean')
|
||||||
|
}
|
||||||
|
if (params.isRegex !== undefined && typeof params.isRegex !== 'boolean') {
|
||||||
|
throw new Error('Debugger.searchInContent isRegex must be a boolean')
|
||||||
|
}
|
||||||
|
const route = this.scripts.resolve(params.scriptId)
|
||||||
|
if (route === undefined) {
|
||||||
|
if (this.scripts.wasUnsupported(params.scriptId) || params.scriptId.startsWith('client:')) {
|
||||||
|
throw new Error('Client script is no longer available')
|
||||||
|
}
|
||||||
|
return this.native.request('Debugger.searchInContent', params)
|
||||||
|
}
|
||||||
|
const source = await route.source.getScriptSource(route.script.scriptKey)
|
||||||
|
return {
|
||||||
|
result: searchLines(
|
||||||
|
source,
|
||||||
|
params.query,
|
||||||
|
params.caseSensitive === true,
|
||||||
|
params.isRegex === true,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async evaluateOnCallFrame(params: Readonly<Record<string, unknown>>): Promise<object> {
|
||||||
|
const parsed = parseCallFrameEvaluation(params)
|
||||||
|
if (parsed.callFrameId.startsWith('client:')) throw new Error('Client native debugging is unavailable')
|
||||||
|
const realm = this.callFrameRealms.get(parsed.callFrameId) ?? this.supportedDebugger()
|
||||||
|
const objectGroup = parsed.objectGroup ?? 'backtrace'
|
||||||
|
const completion = await debuggerBackend(realm).evaluateOnCallFrame({ ...parsed, objectGroup })
|
||||||
|
return this.runtime.projectCompletion(realm, completion, objectGroup)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pause(): Promise<object> {
|
||||||
|
const supported = this.realms.all().filter(realm => realm.debugger.state === 'supported')
|
||||||
|
if (supported.length === 0) throw new Error('Debugger.pause is unsupported by every active realm')
|
||||||
|
const results = await Promise.all(supported.map(async realm => debuggerBackend(realm).pause()))
|
||||||
|
return mergeResults(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resume(params: Readonly<Record<string, unknown>>): Promise<object> {
|
||||||
|
exactKeys(params, ['terminateOnResume'], 'Debugger.resume parameters')
|
||||||
|
const request = optionalBoolean(params, 'terminateOnResume')
|
||||||
|
const supported = this.realms.all().filter(realm => realm.debugger.state === 'supported')
|
||||||
|
if (supported.length === 0) throw new Error('Debugger.resume is unsupported by every active realm')
|
||||||
|
const results = await Promise.all(supported.map(async realm => debuggerBackend(realm).resume(request)))
|
||||||
|
return mergeResults(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
private forwardNative(request: CdpRequest): void {
|
||||||
|
let params: Readonly<Record<string, unknown>>
|
||||||
|
try {
|
||||||
|
const unsupported = this.unsupportedRoute(request.params)
|
||||||
|
if (unsupported !== undefined) throw new Error(unsupported)
|
||||||
|
params = this.runtime.nativeParameters(request.params)
|
||||||
|
} catch (error) {
|
||||||
|
sendCdpFailure(this.transport, request, error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondToCdpRequest(this.transport, request, async () => this.native.request(request.method, params))
|
||||||
|
}
|
||||||
|
|
||||||
|
private unsupportedRoute(params: Readonly<Record<string, unknown>>): string | undefined {
|
||||||
|
const scriptId = requestScriptId(params)
|
||||||
|
if (scriptId !== undefined) {
|
||||||
|
const route = this.scripts.resolve(scriptId)
|
||||||
|
if (route?.realm.debugger.state === 'unsupported') return route.realm.debugger.reason
|
||||||
|
if (route === undefined && this.scripts.wasUnsupported(scriptId)) return 'Client script is no longer available'
|
||||||
|
}
|
||||||
|
if (typeof params.url === 'string') {
|
||||||
|
const route = this.scripts.byUrl(params.url)
|
||||||
|
if (route?.realm.debugger.state === 'unsupported') return route.realm.debugger.reason
|
||||||
|
}
|
||||||
|
if (typeof params.urlRegex === 'string') {
|
||||||
|
const route = this.scripts.byUrlPattern(params.urlRegex)
|
||||||
|
if (route?.realm.debugger.state === 'unsupported') return route.realm.debugger.reason
|
||||||
|
}
|
||||||
|
if (typeof params.scriptHash === 'string') {
|
||||||
|
const route = this.scripts.byHash(params.scriptHash)
|
||||||
|
if (route?.realm.debugger.state === 'unsupported') return route.realm.debugger.reason
|
||||||
|
}
|
||||||
|
if (typeof params.objectId === 'string') {
|
||||||
|
const route = this.runtime.objectRoute(params.objectId)
|
||||||
|
if (route?.realm.debugger.state === 'unsupported') return route.realm.debugger.reason
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
private receiveRealm(event: InspectorRealmSessionEvent): void {
|
||||||
|
if (event.type === 'opened') {
|
||||||
|
if (this.enabled) void this.enableRealm(event.session).catch((error: unknown) => {
|
||||||
|
console.error(`Inspector could not enable Debugger realm ${event.session.descriptor.label}:`, error)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.sourceDisposers.get(event.session.descriptor.realmId)?.()
|
||||||
|
this.sourceDisposers.delete(event.session.descriptor.realmId)
|
||||||
|
this.debuggerDisposers.get(event.session.descriptor.realmId)?.()
|
||||||
|
this.debuggerDisposers.delete(event.session.descriptor.realmId)
|
||||||
|
for (const [callFrameId, realm] of this.callFrameRealms) {
|
||||||
|
if (realm === event.session) this.callFrameRealms.delete(callFrameId)
|
||||||
|
}
|
||||||
|
this.scripts.removeRealm(event.session)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async enableRealm(realm: InspectorRealmSession): Promise<void> {
|
||||||
|
this.attachCapabilities(realm)
|
||||||
|
if (realm.debugger.state === 'supported') await realm.debugger.backend.enable(this.debuggerEnableRequest)
|
||||||
|
await this.publishCatalog(realm)
|
||||||
|
}
|
||||||
|
|
||||||
|
private attachCapabilities(realm: InspectorRealmSession): void {
|
||||||
|
if (realm.sources.state === 'supported' && !this.sourceDisposers.has(realm.descriptor.realmId)) {
|
||||||
|
const source = realm.sources.backend
|
||||||
|
this.sourceDisposers.set(realm.descriptor.realmId, source.subscribe((script) => {
|
||||||
|
if (this.enabled) this.publishScript(realm, source, script)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
if (realm.debugger.state === 'supported' && !this.debuggerDisposers.has(realm.descriptor.realmId)) {
|
||||||
|
this.debuggerDisposers.set(realm.descriptor.realmId, realm.debugger.backend.subscribe((event) => {
|
||||||
|
if (this.enabled) this.publishDebuggerEvent(realm, event)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async publishCatalog(realm: InspectorRealmSession): Promise<void> {
|
||||||
|
if (!this.enabled || realm.sources.state === 'unsupported') return
|
||||||
|
const scripts = await realm.sources.backend.listScripts()
|
||||||
|
for (const script of scripts) this.publishScript(realm, realm.sources.backend, script)
|
||||||
|
}
|
||||||
|
|
||||||
|
private publishScript(realm: InspectorRealmSession, source: SourceBackend, script: RuntimeScript): void {
|
||||||
|
const registered = this.scripts.register({ realm, source, script })
|
||||||
|
if (registered.fresh) this.transport.send(scriptParsedEvent(realm, script))
|
||||||
|
}
|
||||||
|
|
||||||
|
private publishDebuggerEvent(
|
||||||
|
realm: InspectorRealmSession,
|
||||||
|
event: RuntimeDebuggerEvent<RuntimeBackendObjectHandle>,
|
||||||
|
): void {
|
||||||
|
if (event.type === 'paused') {
|
||||||
|
for (const frame of event.callFrames) this.callFrameRealms.set(frame.callFrameId, realm)
|
||||||
|
} else if (event.type === 'resumed') {
|
||||||
|
for (const [callFrameId, owner] of this.callFrameRealms) {
|
||||||
|
if (owner === realm) this.callFrameRealms.delete(callFrameId)
|
||||||
|
}
|
||||||
|
this.runtime.releaseProjectedGroup('backtrace')
|
||||||
|
}
|
||||||
|
this.transport.send(debuggerEvent(realm, event, this.runtime))
|
||||||
|
}
|
||||||
|
|
||||||
|
private supportedDebugger(): InspectorRealmSession {
|
||||||
|
const realm = this.realms.all().find(candidate => candidate.debugger.state === 'supported')
|
||||||
|
if (realm === undefined) throw new Error('No active realm supports call-frame evaluation')
|
||||||
|
return realm
|
||||||
|
}
|
||||||
|
|
||||||
|
private detachCapabilities(): void {
|
||||||
|
for (const dispose of this.sourceDisposers.values()) dispose()
|
||||||
|
this.sourceDisposers.clear()
|
||||||
|
for (const dispose of this.debuggerDisposers.values()) dispose()
|
||||||
|
this.debuggerDisposers.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private respond(request: CdpRequest, operation: () => Promise<object>): void {
|
||||||
|
respondToCdpRequest(this.transport, request, operation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function debuggerBackend(realm: InspectorRealmSession): DebuggerBackend {
|
||||||
|
if (realm.debugger.state === 'unsupported') throw new Error(realm.debugger.reason)
|
||||||
|
return realm.debugger.backend
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeResults(results: readonly Readonly<Record<string, unknown>>[]): Readonly<Record<string, unknown>> {
|
||||||
|
const merged: Record<string, unknown> = {}
|
||||||
|
for (const result of results) Object.assign(merged, result)
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchLines(
|
||||||
|
source: string,
|
||||||
|
query: string,
|
||||||
|
caseSensitive: boolean,
|
||||||
|
isRegex: boolean,
|
||||||
|
): ReadonlyArray<{ readonly lineNumber: number; readonly lineContent: string }> {
|
||||||
|
const expression = isRegex
|
||||||
|
? new RegExp(query, caseSensitive ? 'u' : 'iu')
|
||||||
|
: undefined
|
||||||
|
const expected = caseSensitive ? query : query.toLowerCase()
|
||||||
|
const result: Array<{ readonly lineNumber: number; readonly lineContent: string }> = []
|
||||||
|
for (const [lineNumber, lineContent] of source.split('\n').entries()) {
|
||||||
|
const matches = expression?.test(lineContent)
|
||||||
|
?? (caseSensitive ? lineContent : lineContent.toLowerCase()).includes(expected)
|
||||||
|
if (matches) result.push({ lineNumber, lineContent })
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/** Explicit adapter for Host-only native CDP methods during realm migration. */
|
||||||
|
|
||||||
|
import { respondToCdpRequest, type CdpRequest, type CdpTransport } from '../protocol.ts'
|
||||||
|
import type { NativeDomainBackend } from '../../../shared/cdp/realm.ts'
|
||||||
|
|
||||||
|
/** Forwards one explicit Host-native domain through a transport-neutral Node session. */
|
||||||
|
export class HostNativeDomainSession {
|
||||||
|
private readonly unsubscribe: () => void
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly transport: CdpTransport,
|
||||||
|
private readonly target: NativeDomainBackend,
|
||||||
|
) {
|
||||||
|
this.unsubscribe = target.subscribe((message) => {
|
||||||
|
if (!this.owns(message.method)
|
||||||
|
|| message.method === 'Runtime.consoleAPICalled'
|
||||||
|
|| message.method === 'Runtime.exceptionThrown') return
|
||||||
|
this.transport.send(message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute one Host-native CDP request and send its correlated result.
|
||||||
|
* @param request - Parsed request owned by a native Host domain.
|
||||||
|
* @returns Whether this adapter owns the request's domain.
|
||||||
|
*/
|
||||||
|
handle(request: CdpRequest): boolean {
|
||||||
|
if (!this.owns(request.method)) return false
|
||||||
|
respondToCdpRequest(this.transport, request, async () => this.target.request(request.method, request.params))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test whether this adapter owns a CDP method.
|
||||||
|
* @param method - CDP method name.
|
||||||
|
* @returns Whether the method belongs to an explicit Host-native domain.
|
||||||
|
*/
|
||||||
|
owns(method: string): boolean {
|
||||||
|
return NATIVE_DOMAINS.has(method.slice(0, method.indexOf('.')))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop forwarding native notifications to this DevTools connection. */
|
||||||
|
close(): void {
|
||||||
|
this.unsubscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const NATIVE_DOMAINS = new Set(['Runtime', 'Profiler', 'HeapProfiler', 'Schema'])
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
/** Validation and normalization of CDP Runtime parameters routed to a Client realm. */
|
||||||
|
|
||||||
|
import type { RuntimeBackendObjectHandle } from '../../../../shared/cdp/ids.ts'
|
||||||
|
import { isJsonValue, isPlainObject, type InspectorJsonValue } from '../../../../shared/json.ts'
|
||||||
|
import type {
|
||||||
|
RuntimeAwaitPromiseRequest,
|
||||||
|
RuntimeCallFunctionRequest,
|
||||||
|
RuntimeEvaluateRequest,
|
||||||
|
RuntimeGetPropertiesRequest,
|
||||||
|
} from '../../../../shared/cdp/index.ts'
|
||||||
|
import { exactKeys, optionalBoolean, optionalString } from '../../../../shared/validation.ts'
|
||||||
|
|
||||||
|
/** Numeric or globally unique selector for one execution context. */
|
||||||
|
export interface CdpExecutionContextSelector {
|
||||||
|
readonly contextId?: number
|
||||||
|
readonly executionContextId?: number
|
||||||
|
readonly uniqueContextId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validated Runtime.evaluate parameters and their routing selector. */
|
||||||
|
export interface ParsedEvaluate extends CdpExecutionContextSelector {
|
||||||
|
readonly request: RuntimeEvaluateRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Client-independent call argument before object ids are routed. */
|
||||||
|
export type CdpCallArgument =
|
||||||
|
| { readonly kind: 'value'; readonly value: InspectorJsonValue }
|
||||||
|
| { readonly kind: 'unserializable'; readonly value: string }
|
||||||
|
| { readonly kind: 'object'; readonly objectId: string }
|
||||||
|
| { readonly kind: 'undefined' }
|
||||||
|
|
||||||
|
/** Validated Runtime.callFunctionOn parameters before object-id routing. */
|
||||||
|
export interface ParsedCallFunction extends CdpExecutionContextSelector {
|
||||||
|
readonly objectId?: string
|
||||||
|
readonly arguments: readonly CdpCallArgument[]
|
||||||
|
readonly request: Omit<RuntimeCallFunctionRequest<RuntimeBackendObjectHandle>, 'receiver' | 'arguments'>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse realm-routed `Runtime.evaluate` parameters.
|
||||||
|
* @param params - Untrusted CDP parameters.
|
||||||
|
* @returns A context selector and normalized Runtime request.
|
||||||
|
*/
|
||||||
|
export function parseEvaluate(params: Readonly<Record<string, unknown>>): ParsedEvaluate {
|
||||||
|
exactKeys(params, [
|
||||||
|
'expression', 'objectGroup', 'includeCommandLineAPI', 'silent', 'contextId', 'returnByValue',
|
||||||
|
'generatePreview', 'userGesture', 'awaitPromise', 'throwOnSideEffect', 'timeout', 'disableBreaks',
|
||||||
|
'replMode', 'allowUnsafeEvalBlockedByCSP', 'uniqueContextId', 'serializationOptions',
|
||||||
|
], 'Runtime.evaluate params')
|
||||||
|
if (typeof params.expression !== 'string') throw new Error('Runtime.evaluate expression must be a string')
|
||||||
|
const selector = parseContextSelector(params, 'contextId')
|
||||||
|
const timeout = params.timeout
|
||||||
|
if (timeout !== undefined && (typeof timeout !== 'number' || !Number.isFinite(timeout) || timeout < 0)) {
|
||||||
|
throw new Error('Runtime.evaluate timeout must be a non-negative finite number')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...selector,
|
||||||
|
request: {
|
||||||
|
expression: params.expression,
|
||||||
|
...optionalString(params, 'objectGroup'),
|
||||||
|
...optionalBoolean(params, 'includeCommandLineAPI'),
|
||||||
|
...optionalBoolean(params, 'silent'),
|
||||||
|
...optionalBoolean(params, 'returnByValue'),
|
||||||
|
...optionalBoolean(params, 'generatePreview'),
|
||||||
|
...optionalBoolean(params, 'userGesture'),
|
||||||
|
...optionalBoolean(params, 'awaitPromise'),
|
||||||
|
...optionalBoolean(params, 'disableBreaks'),
|
||||||
|
...optionalBoolean(params, 'replMode'),
|
||||||
|
...optionalBoolean(params, 'allowUnsafeEvalBlockedByCSP'),
|
||||||
|
...optionalBoolean(params, 'throwOnSideEffect'),
|
||||||
|
...optionalJsonObject(params, 'serializationOptions'),
|
||||||
|
...(timeout === undefined ? {} : { timeoutMs: timeout }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse realm-routed `Runtime.getProperties` parameters.
|
||||||
|
* @param params - Untrusted CDP parameters.
|
||||||
|
* @returns The external object id and handle-free Runtime request.
|
||||||
|
*/
|
||||||
|
export function parseGetProperties(
|
||||||
|
params: Readonly<Record<string, unknown>>,
|
||||||
|
): {
|
||||||
|
readonly objectId: string
|
||||||
|
readonly request: Omit<RuntimeGetPropertiesRequest<RuntimeBackendObjectHandle>, 'handle'>
|
||||||
|
} {
|
||||||
|
exactKeys(params, [
|
||||||
|
'objectId', 'ownProperties', 'accessorPropertiesOnly', 'generatePreview', 'nonIndexedPropertiesOnly',
|
||||||
|
], 'Runtime.getProperties params')
|
||||||
|
if (typeof params.objectId !== 'string') throw new Error('Runtime.getProperties objectId must be a string')
|
||||||
|
return {
|
||||||
|
objectId: params.objectId,
|
||||||
|
request: {
|
||||||
|
...optionalBoolean(params, 'ownProperties'),
|
||||||
|
...optionalBoolean(params, 'accessorPropertiesOnly'),
|
||||||
|
...optionalBoolean(params, 'generatePreview'),
|
||||||
|
...optionalBoolean(params, 'nonIndexedPropertiesOnly'),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Client-routed `Runtime.callFunctionOn` parameters.
|
||||||
|
* @param params - Untrusted CDP parameters.
|
||||||
|
* @returns Routing fields, arguments, and a handle-free Runtime request.
|
||||||
|
*/
|
||||||
|
export function parseCallFunction(params: Readonly<Record<string, unknown>>): ParsedCallFunction {
|
||||||
|
exactKeys(params, [
|
||||||
|
'functionDeclaration', 'objectId', 'arguments', 'silent', 'returnByValue', 'generatePreview', 'userGesture',
|
||||||
|
'awaitPromise', 'executionContextId', 'objectGroup', 'throwOnSideEffect', 'uniqueContextId', 'serializationOptions',
|
||||||
|
], 'Runtime.callFunctionOn params')
|
||||||
|
if (typeof params.functionDeclaration !== 'string') {
|
||||||
|
throw new Error('Runtime.callFunctionOn functionDeclaration must be a string')
|
||||||
|
}
|
||||||
|
const selector = parseContextSelector(params, 'executionContextId')
|
||||||
|
const objectId = optionalObjectId(params.objectId, 'Runtime.callFunctionOn objectId')
|
||||||
|
if (objectId === undefined
|
||||||
|
&& selector.executionContextId === undefined
|
||||||
|
&& selector.uniqueContextId === undefined) {
|
||||||
|
throw new Error('Runtime.callFunctionOn requires objectId or an execution context')
|
||||||
|
}
|
||||||
|
if (objectId !== undefined && (selector.executionContextId !== undefined || selector.uniqueContextId !== undefined)) {
|
||||||
|
throw new Error('Runtime.callFunctionOn objectId and execution context are mutually exclusive')
|
||||||
|
}
|
||||||
|
let args: readonly CdpCallArgument[] = []
|
||||||
|
if (params.arguments !== undefined) {
|
||||||
|
if (!Array.isArray(params.arguments)) throw new Error('Runtime.callFunctionOn arguments must be an array')
|
||||||
|
args = params.arguments.map(parseCallArgument)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...selector,
|
||||||
|
...(objectId === undefined ? {} : { objectId }),
|
||||||
|
arguments: args,
|
||||||
|
request: {
|
||||||
|
functionDeclaration: params.functionDeclaration,
|
||||||
|
...optionalString(params, 'objectGroup'),
|
||||||
|
...optionalBoolean(params, 'silent'),
|
||||||
|
...optionalBoolean(params, 'returnByValue'),
|
||||||
|
...optionalBoolean(params, 'generatePreview'),
|
||||||
|
...optionalBoolean(params, 'userGesture'),
|
||||||
|
...optionalBoolean(params, 'awaitPromise'),
|
||||||
|
...optionalBoolean(params, 'throwOnSideEffect'),
|
||||||
|
...optionalJsonObject(params, 'serializationOptions'),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Client-routed `Runtime.awaitPromise` parameters.
|
||||||
|
* @param params - Untrusted CDP parameters.
|
||||||
|
* @returns The external promise id and handle-free Runtime request.
|
||||||
|
*/
|
||||||
|
export function parseAwaitPromise(params: Readonly<Record<string, unknown>>): {
|
||||||
|
readonly promiseObjectId: string
|
||||||
|
readonly request: Omit<RuntimeAwaitPromiseRequest<RuntimeBackendObjectHandle>, 'promise'>
|
||||||
|
} {
|
||||||
|
exactKeys(params, ['promiseObjectId', 'returnByValue', 'generatePreview'], 'Runtime.awaitPromise params')
|
||||||
|
if (typeof params.promiseObjectId !== 'string') throw new Error('Runtime.awaitPromise promiseObjectId must be a string')
|
||||||
|
return {
|
||||||
|
promiseObjectId: params.promiseObjectId,
|
||||||
|
request: {
|
||||||
|
...optionalBoolean(params, 'returnByValue'),
|
||||||
|
...optionalBoolean(params, 'generatePreview'),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse one required object id.
|
||||||
|
* @param params - Untrusted CDP parameters.
|
||||||
|
* @returns The object id.
|
||||||
|
*/
|
||||||
|
export function parseReleaseObject(params: Readonly<Record<string, unknown>>): string {
|
||||||
|
exactKeys(params, ['objectId'], 'Runtime.releaseObject params')
|
||||||
|
if (typeof params.objectId !== 'string') throw new Error('Runtime.releaseObject objectId must be a string')
|
||||||
|
return params.objectId
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse one required object-group name.
|
||||||
|
* @param params - Untrusted CDP parameters.
|
||||||
|
* @returns The object-group name.
|
||||||
|
*/
|
||||||
|
export function parseReleaseObjectGroup(params: Readonly<Record<string, unknown>>): string {
|
||||||
|
exactKeys(params, ['objectGroup'], 'Runtime.releaseObjectGroup params')
|
||||||
|
if (typeof params.objectGroup !== 'string') throw new Error('Runtime.releaseObjectGroup objectGroup must be a string')
|
||||||
|
return params.objectGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse `Runtime.globalLexicalScopeNames` context selection.
|
||||||
|
* @param params - Untrusted CDP parameters.
|
||||||
|
* @returns The validated context selector.
|
||||||
|
*/
|
||||||
|
export function parseGlobalLexicalScopeNames(params: Readonly<Record<string, unknown>>): CdpExecutionContextSelector {
|
||||||
|
exactKeys(params, ['executionContextId', 'uniqueContextId'], 'Runtime.globalLexicalScopeNames params')
|
||||||
|
return parseContextSelector(params, 'executionContextId')
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCallArgument(value: unknown): CdpCallArgument {
|
||||||
|
if (!isPlainObject(value)) throw new Error('Runtime.callFunctionOn argument must be an object')
|
||||||
|
exactKeys(value, ['value', 'unserializableValue', 'objectId'], 'Runtime.callFunctionOn argument')
|
||||||
|
const present = ['value', 'unserializableValue', 'objectId'].filter(key => Object.hasOwn(value, key))
|
||||||
|
if (present.length > 1) throw new Error('Runtime.callFunctionOn argument has multiple value representations')
|
||||||
|
if (present.length === 0) return { kind: 'undefined' }
|
||||||
|
if (present[0] === 'value') {
|
||||||
|
if (!isJsonValue(value.value)) throw new Error('Runtime.callFunctionOn argument value must be JSON')
|
||||||
|
return { kind: 'value', value: value.value }
|
||||||
|
}
|
||||||
|
if (present[0] === 'unserializableValue') {
|
||||||
|
if (typeof value.unserializableValue !== 'string') {
|
||||||
|
throw new Error('Runtime.callFunctionOn unserializableValue must be a string')
|
||||||
|
}
|
||||||
|
return { kind: 'unserializable', value: value.unserializableValue }
|
||||||
|
}
|
||||||
|
if (typeof value.objectId !== 'string') throw new Error('Runtime.callFunctionOn argument objectId must be a string')
|
||||||
|
return { kind: 'object', objectId: value.objectId }
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseContextSelector(
|
||||||
|
params: Readonly<Record<string, unknown>>,
|
||||||
|
numericKey: 'contextId' | 'executionContextId',
|
||||||
|
): CdpExecutionContextSelector {
|
||||||
|
const numeric = params[numericKey]
|
||||||
|
const unique = params.uniqueContextId
|
||||||
|
if (numeric !== undefined && (!Number.isSafeInteger(numeric))) {
|
||||||
|
throw new Error(`Runtime ${numericKey} must be an integer`)
|
||||||
|
}
|
||||||
|
if (unique !== undefined && typeof unique !== 'string') throw new Error('Runtime uniqueContextId must be a string')
|
||||||
|
if (numeric !== undefined && unique !== undefined) throw new Error('Runtime context selectors are mutually exclusive')
|
||||||
|
return {
|
||||||
|
...(numeric === undefined
|
||||||
|
? {}
|
||||||
|
: numericKey === 'contextId'
|
||||||
|
? { contextId: numeric as number }
|
||||||
|
: { executionContextId: numeric as number }),
|
||||||
|
...(unique === undefined ? {} : { uniqueContextId: unique }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalObjectId(value: unknown, label: string): string | undefined {
|
||||||
|
if (value === undefined) return undefined
|
||||||
|
if (typeof value !== 'string') throw new Error(`${label} must be a string`)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalJsonObject<Key extends string>(
|
||||||
|
value: Readonly<Record<string, unknown>>,
|
||||||
|
key: Key,
|
||||||
|
): Partial<Record<Key, Readonly<Record<string, InspectorJsonValue>>>> {
|
||||||
|
const item = value[key]
|
||||||
|
if (item === undefined) return {}
|
||||||
|
if (!isPlainObject(item) || !isJsonValue(item)) throw new Error(`Runtime ${key} must be a JSON object`)
|
||||||
|
return { [key]: item } as Partial<Record<Key, Readonly<Record<string, InspectorJsonValue>>>>
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/** Client-aware Runtime domain exports. */
|
||||||
|
|
||||||
|
export { RuntimeDomainSession } from './session.ts'
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
/** Per-CDP-connection routing and projection for every realm's Runtime objects. */
|
||||||
|
|
||||||
|
import type {
|
||||||
|
RuntimeCompletion,
|
||||||
|
RuntimeConsoleBackendEvent,
|
||||||
|
RuntimeExceptionDetails,
|
||||||
|
RuntimeInternalPropertyDescriptor,
|
||||||
|
RuntimePrivatePropertyDescriptor,
|
||||||
|
RuntimeProperties,
|
||||||
|
RuntimePropertyDescriptor,
|
||||||
|
RuntimeRemoteObject,
|
||||||
|
RuntimeStackTrace,
|
||||||
|
} from '../../../../shared/cdp/index.ts'
|
||||||
|
import type { InspectorObjectReference } from '../../../../shared/cordis/object-reference.ts'
|
||||||
|
import type { RuntimeBackendObjectHandle } from '../../../../shared/cdp/ids.ts'
|
||||||
|
import type { InspectorRealmDescriptor, InspectorRealmSession } from '../../../inspection/realm.ts'
|
||||||
|
import { cdpStringId, type CdpRemoteObjectId, type InspectorConnectionId } from '../../ids.ts'
|
||||||
|
|
||||||
|
/** Object retained behind one connection-local CDP object id. */
|
||||||
|
export interface RuntimeObjectRoute {
|
||||||
|
readonly realm: InspectorRealmSession
|
||||||
|
readonly handle: RuntimeBackendObjectHandle
|
||||||
|
readonly group: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Semantic presentation applied when an object belongs to a projected node. */
|
||||||
|
export interface RuntimeObjectPresentation {
|
||||||
|
readonly subtype: 'node'
|
||||||
|
readonly className: string
|
||||||
|
readonly description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Observer of newly exposed Runtime object ids. */
|
||||||
|
export type RuntimeObjectObserver = (
|
||||||
|
objectId: CdpRemoteObjectId,
|
||||||
|
realm: InspectorRealmDescriptor,
|
||||||
|
reference: InspectorObjectReference,
|
||||||
|
group: string | undefined,
|
||||||
|
) => RuntimeObjectPresentation | undefined
|
||||||
|
|
||||||
|
/** CDP Runtime payload derived from one realm completion. */
|
||||||
|
export interface CdpRuntimeCompletion {
|
||||||
|
readonly result: Readonly<Record<string, unknown>>
|
||||||
|
readonly exceptionDetails?: Readonly<Record<string, unknown>>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CDP Runtime payload derived from one realm's property descriptors. */
|
||||||
|
export interface CdpGetPropertiesResult {
|
||||||
|
readonly result: readonly Readonly<Record<string, unknown>>[]
|
||||||
|
readonly internalProperties?: readonly Readonly<Record<string, unknown>>[]
|
||||||
|
readonly privateProperties?: readonly Readonly<Record<string, unknown>>[]
|
||||||
|
readonly exceptionDetails?: Readonly<Record<string, unknown>>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One CDP notification projected from a realm Console event. */
|
||||||
|
export interface CdpRuntimeEvent {
|
||||||
|
readonly method: 'Runtime.consoleAPICalled' | 'Runtime.exceptionThrown'
|
||||||
|
readonly params: Readonly<Record<string, unknown>>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps every realm's backend handles to object ids scoped to one CDP connection. */
|
||||||
|
export class RuntimeObjectTable {
|
||||||
|
private readonly routes = new Map<CdpRemoteObjectId, RuntimeObjectRoute>()
|
||||||
|
private nextObjectId = 1
|
||||||
|
private nextExceptionId = 1
|
||||||
|
private observer: RuntimeObjectObserver | undefined
|
||||||
|
|
||||||
|
constructor(private readonly connectionId: InspectorConnectionId) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install Cordis object recognition after Runtime and DOM sessions are assembled.
|
||||||
|
* @param observer - Callback mapping a semantic reference to node presentation.
|
||||||
|
*/
|
||||||
|
setObserver(observer: RuntimeObjectObserver): void {
|
||||||
|
this.observer = observer
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve one connection-local object id.
|
||||||
|
* @param objectId - CDP object id allocated by this table.
|
||||||
|
* @returns Its realm and backend handle when current.
|
||||||
|
*/
|
||||||
|
resolve(objectId: string): RuntimeObjectRoute | undefined {
|
||||||
|
return this.routes.get(cdpStringId<'CdpRemoteObjectId'>(objectId, 'objectId'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a realm completion to CDP fields.
|
||||||
|
* @param realm - Realm session that produced the value.
|
||||||
|
* @param value - Engine-independent completion.
|
||||||
|
* @param group - Object group inherited by exposed handles.
|
||||||
|
* @returns CDP Runtime completion fields.
|
||||||
|
*/
|
||||||
|
completion(
|
||||||
|
realm: InspectorRealmSession,
|
||||||
|
value: RuntimeCompletion<RuntimeBackendObjectHandle>,
|
||||||
|
group: string | undefined,
|
||||||
|
): CdpRuntimeCompletion {
|
||||||
|
return {
|
||||||
|
result: this.remote(realm, value.result, group),
|
||||||
|
...(value.exceptionDetails === undefined
|
||||||
|
? {}
|
||||||
|
: { exceptionDetails: this.exception(realm, value.exceptionDetails, group) }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert realm property descriptors to CDP fields.
|
||||||
|
* @param realm - Realm session that owns returned object references.
|
||||||
|
* @param value - Engine-independent property result.
|
||||||
|
* @param group - Object group inherited from the inspected object.
|
||||||
|
* @returns CDP Runtime property result fields.
|
||||||
|
*/
|
||||||
|
properties(
|
||||||
|
realm: InspectorRealmSession,
|
||||||
|
value: RuntimeProperties<RuntimeBackendObjectHandle>,
|
||||||
|
group: string | undefined,
|
||||||
|
): CdpGetPropertiesResult {
|
||||||
|
return {
|
||||||
|
result: value.properties.map(property => this.property(realm, property, group)),
|
||||||
|
...(value.internalProperties === undefined
|
||||||
|
? {}
|
||||||
|
: { internalProperties: value.internalProperties.map(property => this.internalProperty(realm, property, group)) }),
|
||||||
|
...(value.privateProperties === undefined
|
||||||
|
? {}
|
||||||
|
: { privateProperties: value.privateProperties.map(property => this.privateProperty(realm, property, group)) }),
|
||||||
|
...(value.exceptionDetails === undefined
|
||||||
|
? {}
|
||||||
|
: { exceptionDetails: this.exception(realm, value.exceptionDetails, group) }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project one realm Console event to a CDP Runtime notification.
|
||||||
|
* @param realm - Realm session that emitted the event.
|
||||||
|
* @param value - Realm-neutral Console or exception event.
|
||||||
|
* @returns CDP method and parameters.
|
||||||
|
*/
|
||||||
|
consoleEvent(
|
||||||
|
realm: InspectorRealmSession,
|
||||||
|
value: RuntimeConsoleBackendEvent<RuntimeBackendObjectHandle>,
|
||||||
|
): CdpRuntimeEvent {
|
||||||
|
if (value.type === 'console-api') {
|
||||||
|
const contextId = value.event.contextId
|
||||||
|
?? (realm.context.kind === 'synthetic' ? realm.context.id : undefined)
|
||||||
|
return {
|
||||||
|
method: 'Runtime.consoleAPICalled',
|
||||||
|
params: {
|
||||||
|
type: value.event.type,
|
||||||
|
args: value.event.arguments.map(argument => this.remote(realm, argument, 'console')),
|
||||||
|
timestamp: value.event.timestamp,
|
||||||
|
...(contextId === undefined ? {} : { executionContextId: contextId }),
|
||||||
|
...(value.event.stackTrace === undefined ? {} : { stackTrace: cdpStackTrace(value.event.stackTrace) }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const contextId = value.event.contextId
|
||||||
|
?? (realm.context.kind === 'synthetic' ? realm.context.id : undefined)
|
||||||
|
return {
|
||||||
|
method: 'Runtime.exceptionThrown',
|
||||||
|
params: {
|
||||||
|
timestamp: value.event.timestamp,
|
||||||
|
exceptionDetails: {
|
||||||
|
...this.exception(realm, value.event.details, 'console'),
|
||||||
|
...(contextId === undefined ? {} : { executionContextId: contextId }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List realm sessions retaining at least one object in a group.
|
||||||
|
* @param group - DevTools object-group name.
|
||||||
|
* @returns Distinct realm sessions that must receive the release.
|
||||||
|
*/
|
||||||
|
realmsInGroup(group: string): InspectorRealmSession[] {
|
||||||
|
const realms = new Set<InspectorRealmSession>()
|
||||||
|
for (const route of this.routes.values()) {
|
||||||
|
if (route.group === group) realms.add(route.realm)
|
||||||
|
}
|
||||||
|
return [...realms]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forget one externally visible object id.
|
||||||
|
* @param objectId - Released CDP object id.
|
||||||
|
*/
|
||||||
|
release(objectId: string): void {
|
||||||
|
this.routes.delete(cdpStringId<'CdpRemoteObjectId'>(objectId, 'objectId'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forget all ids retained under one object group.
|
||||||
|
* @param group - Released object-group name.
|
||||||
|
*/
|
||||||
|
releaseGroup(group: string): void {
|
||||||
|
for (const [objectId, route] of this.routes) {
|
||||||
|
if (route.group === group) this.routes.delete(objectId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forget every object owned by one closed realm session.
|
||||||
|
* @param realm - Closed realm session.
|
||||||
|
*/
|
||||||
|
releaseRealm(realm: InspectorRealmSession): void {
|
||||||
|
for (const [objectId, route] of this.routes) {
|
||||||
|
if (route.realm === realm) this.routes.delete(objectId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forget every object exposed on this DevTools connection. */
|
||||||
|
clear(): void {
|
||||||
|
this.routes.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project one common Runtime value and retain its backend handle for this connection.
|
||||||
|
* @param realm - Realm session that owns the value.
|
||||||
|
* @param value - Realm-neutral Runtime value.
|
||||||
|
* @param group - Object group assigned to any exposed handle.
|
||||||
|
* @returns CDP RemoteObject fields.
|
||||||
|
*/
|
||||||
|
remote(
|
||||||
|
realm: InspectorRealmSession,
|
||||||
|
value: RuntimeRemoteObject<RuntimeBackendObjectHandle>,
|
||||||
|
group: string | undefined,
|
||||||
|
): Readonly<Record<string, unknown>> {
|
||||||
|
const objectId = value.object === undefined
|
||||||
|
? undefined
|
||||||
|
: this.expose(realm, value.object.handle, group)
|
||||||
|
const presentation = objectId === undefined || value.semanticReference === undefined
|
||||||
|
? undefined
|
||||||
|
: this.observer?.(objectId, realm.descriptor, value.semanticReference, group)
|
||||||
|
const descriptor = value.descriptor
|
||||||
|
return {
|
||||||
|
...descriptor,
|
||||||
|
...(presentation?.subtype === undefined ? {} : { subtype: presentation.subtype }),
|
||||||
|
...(presentation?.className === undefined ? {} : { className: presentation.className }),
|
||||||
|
...(presentation?.description === undefined ? {} : { description: presentation.description }),
|
||||||
|
...(objectId === undefined ? {} : { objectId }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private property(
|
||||||
|
realm: InspectorRealmSession,
|
||||||
|
property: RuntimePropertyDescriptor<RuntimeBackendObjectHandle>,
|
||||||
|
group: string | undefined,
|
||||||
|
): Readonly<Record<string, unknown>> {
|
||||||
|
return {
|
||||||
|
...property,
|
||||||
|
...(property.value === undefined ? {} : { value: this.remote(realm, property.value, group) }),
|
||||||
|
...(property.get === undefined ? {} : { get: this.remote(realm, property.get, group) }),
|
||||||
|
...(property.set === undefined ? {} : { set: this.remote(realm, property.set, group) }),
|
||||||
|
...(property.symbol === undefined ? {} : { symbol: this.remote(realm, property.symbol, group) }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private internalProperty(
|
||||||
|
realm: InspectorRealmSession,
|
||||||
|
property: RuntimeInternalPropertyDescriptor<RuntimeBackendObjectHandle>,
|
||||||
|
group: string | undefined,
|
||||||
|
): Readonly<Record<string, unknown>> {
|
||||||
|
return {
|
||||||
|
name: property.name,
|
||||||
|
...(property.value === undefined ? {} : { value: this.remote(realm, property.value, group) }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private privateProperty(
|
||||||
|
realm: InspectorRealmSession,
|
||||||
|
property: RuntimePrivatePropertyDescriptor<RuntimeBackendObjectHandle>,
|
||||||
|
group: string | undefined,
|
||||||
|
): Readonly<Record<string, unknown>> {
|
||||||
|
return {
|
||||||
|
name: property.name,
|
||||||
|
...(property.value === undefined ? {} : { value: this.remote(realm, property.value, group) }),
|
||||||
|
...(property.get === undefined ? {} : { get: this.remote(realm, property.get, group) }),
|
||||||
|
...(property.set === undefined ? {} : { set: this.remote(realm, property.set, group) }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private exception(
|
||||||
|
realm: InspectorRealmSession,
|
||||||
|
details: RuntimeExceptionDetails<RuntimeBackendObjectHandle>,
|
||||||
|
group: string | undefined,
|
||||||
|
): Readonly<Record<string, unknown>> {
|
||||||
|
return {
|
||||||
|
...details,
|
||||||
|
exceptionId: this.nextExceptionId++,
|
||||||
|
...(realm.context.kind === 'synthetic' ? { executionContextId: realm.context.id } : {}),
|
||||||
|
...(details.stackTrace === undefined ? {} : { stackTrace: cdpStackTrace(details.stackTrace) }),
|
||||||
|
...(details.exception === undefined ? {} : { exception: this.remote(realm, details.exception, group) }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private expose(
|
||||||
|
realm: InspectorRealmSession,
|
||||||
|
handle: RuntimeBackendObjectHandle,
|
||||||
|
group: string | undefined,
|
||||||
|
): CdpRemoteObjectId {
|
||||||
|
const objectId = cdpStringId<'CdpRemoteObjectId'>(
|
||||||
|
`runtime:${this.connectionId}:${String(this.nextObjectId++)}`,
|
||||||
|
'objectId',
|
||||||
|
)
|
||||||
|
this.routes.set(objectId, { realm, handle, group })
|
||||||
|
return objectId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cdpStackTrace(stack: RuntimeStackTrace): Readonly<Record<string, unknown>> {
|
||||||
|
return {
|
||||||
|
...(stack.description === undefined ? {} : { description: stack.description }),
|
||||||
|
callFrames: stack.callFrames.map(frame => ({
|
||||||
|
functionName: frame.functionName,
|
||||||
|
scriptId: frame.scriptKey ?? '0',
|
||||||
|
url: frame.url,
|
||||||
|
lineNumber: frame.lineNumber,
|
||||||
|
columnNumber: frame.columnNumber,
|
||||||
|
})),
|
||||||
|
...(stack.parent === undefined ? {} : { parent: cdpStackTrace(stack.parent) }),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
/** Per-DevTools-session Runtime routing across uniform Host and Client realms. */
|
||||||
|
|
||||||
|
import type { InspectorSourceDescriptor } from '../../../../shared/bridge/messages/observation.ts'
|
||||||
|
import type { InspectorRealmId, RuntimeBackendObjectHandle } from '../../../../shared/cdp/ids.ts'
|
||||||
|
import type { RuntimeCallArgument, RuntimeCompletion, RuntimeRemoteObject } from '../../../../shared/cdp/index.ts'
|
||||||
|
import type { RuntimeBackend } from '../../../../shared/cdp/realm.ts'
|
||||||
|
import { cdpError, respondToCdpRequest, type CdpRequest, type CdpTransport } from '../../protocol.ts'
|
||||||
|
import type { InspectorRealmSession } from '../../../inspection/realm.ts'
|
||||||
|
import type { InspectorRealmSessionEvent, InspectorRealmSessionSet } from '../../realm-sessions.ts'
|
||||||
|
import {
|
||||||
|
parseAwaitPromise,
|
||||||
|
parseCallFunction,
|
||||||
|
parseEvaluate,
|
||||||
|
parseGetProperties,
|
||||||
|
parseGlobalLexicalScopeNames,
|
||||||
|
parseReleaseObject,
|
||||||
|
parseReleaseObjectGroup,
|
||||||
|
type CdpCallArgument,
|
||||||
|
type CdpExecutionContextSelector,
|
||||||
|
} from './cdp-params.ts'
|
||||||
|
import { RuntimeObjectTable, type RuntimeObjectObserver } from './object-table.ts'
|
||||||
|
import type { RuntimeObjectRoute } from './object-table.ts'
|
||||||
|
|
||||||
|
/** Runtime router layered over the common per-connection realm sessions. */
|
||||||
|
export class RuntimeDomainSession {
|
||||||
|
private readonly objects: RuntimeObjectTable
|
||||||
|
private readonly announcedContexts = new Set<number>()
|
||||||
|
private readonly consoleDisposers = new Map<InspectorRealmId, () => void>()
|
||||||
|
private readonly unsubscribeRealms: () => void
|
||||||
|
private enabled = false
|
||||||
|
private closed = false
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly transport: CdpTransport,
|
||||||
|
private readonly realms: InspectorRealmSessionSet,
|
||||||
|
) {
|
||||||
|
this.objects = new RuntimeObjectTable(realms.connectionId)
|
||||||
|
this.unsubscribeRealms = realms.subscribe((event) => { this.receiveRealm(event) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle methods that require cross-realm Runtime coordination.
|
||||||
|
* @param request - Parsed CDP request.
|
||||||
|
* @returns Whether this domain owns the method or object id.
|
||||||
|
*/
|
||||||
|
handle(request: CdpRequest): boolean {
|
||||||
|
switch (request.method) {
|
||||||
|
case 'Runtime.enable':
|
||||||
|
this.respond(request, () => this.enable())
|
||||||
|
return true
|
||||||
|
case 'Runtime.disable':
|
||||||
|
this.respond(request, () => this.disable())
|
||||||
|
return true
|
||||||
|
case 'Runtime.evaluate':
|
||||||
|
this.respond(request, () => this.evaluate(request.params))
|
||||||
|
return true
|
||||||
|
case 'Runtime.getProperties':
|
||||||
|
return this.getProperties(request)
|
||||||
|
case 'Runtime.callFunctionOn':
|
||||||
|
return this.callFunction(request)
|
||||||
|
case 'Runtime.awaitPromise':
|
||||||
|
return this.awaitPromise(request)
|
||||||
|
case 'Runtime.releaseObject':
|
||||||
|
return this.releaseObject(request)
|
||||||
|
case 'Runtime.releaseObjectGroup':
|
||||||
|
this.respond(request, () => this.releaseObjectGroup(request.params))
|
||||||
|
return true
|
||||||
|
case 'Runtime.globalLexicalScopeNames':
|
||||||
|
this.respond(request, () => this.globalLexicalScopeNames(request.params))
|
||||||
|
return true
|
||||||
|
case 'Runtime.discardConsoleEntries':
|
||||||
|
this.respond(request, () => this.discardConsoleEntries())
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
if (request.method.startsWith('Runtime.')) {
|
||||||
|
const reason = this.unsupportedNativeRoute(request.params)
|
||||||
|
if (reason !== undefined) {
|
||||||
|
this.sendError(request, reason)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Release this connection's object routes and realm subscription. */
|
||||||
|
close(): void {
|
||||||
|
if (this.closed) return
|
||||||
|
this.closed = true
|
||||||
|
this.unsubscribeRealms()
|
||||||
|
for (const dispose of this.consoleDisposers.values()) dispose()
|
||||||
|
this.consoleDisposers.clear()
|
||||||
|
this.objects.clear()
|
||||||
|
this.announcedContexts.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install semantic object recognition shared with the DOM adapter.
|
||||||
|
* @param observer - Callback invoked for objects carrying semantic references.
|
||||||
|
*/
|
||||||
|
setObjectObserver(observer: RuntimeObjectObserver): void {
|
||||||
|
this.objects.setObserver(observer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a connection-local CDP object id for another domain adapter.
|
||||||
|
* @param objectId - CDP object id allocated by this Runtime session.
|
||||||
|
* @returns Its realm and backend handle when still live.
|
||||||
|
*/
|
||||||
|
objectRoute(objectId: string): RuntimeObjectRoute | undefined {
|
||||||
|
return this.objects.resolve(objectId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project a completion produced by another domain through this connection's object table.
|
||||||
|
* @param realm - Realm session that owns the completion.
|
||||||
|
* @param completion - Realm-neutral result and exception fields.
|
||||||
|
* @param group - Object group assigned to exposed handles.
|
||||||
|
* @returns CDP Runtime result fields.
|
||||||
|
*/
|
||||||
|
projectCompletion(
|
||||||
|
realm: InspectorRealmSession,
|
||||||
|
completion: RuntimeCompletion<RuntimeBackendObjectHandle>,
|
||||||
|
group: string | undefined,
|
||||||
|
): object {
|
||||||
|
return this.objects.completion(realm, completion, group)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project one Runtime value produced by another domain.
|
||||||
|
* @param realm - Realm session that owns the value.
|
||||||
|
* @param value - Realm-neutral Runtime value.
|
||||||
|
* @param group - Object group assigned to an exposed handle.
|
||||||
|
* @returns CDP RemoteObject fields.
|
||||||
|
*/
|
||||||
|
projectRemoteObject(
|
||||||
|
realm: InspectorRealmSession,
|
||||||
|
value: RuntimeRemoteObject<RuntimeBackendObjectHandle>,
|
||||||
|
group: string | undefined,
|
||||||
|
): Readonly<Record<string, unknown>> {
|
||||||
|
return this.objects.remote(realm, value, group)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forget connection-local ids retained for another domain's object group.
|
||||||
|
* @param group - Object group whose projected ids have expired.
|
||||||
|
*/
|
||||||
|
releaseProjectedGroup(group: string): void {
|
||||||
|
this.objects.releaseGroup(group)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace common object ids with native backend handles in a Host-only request.
|
||||||
|
* @param params - Parsed CDP parameters that may contain nested object ids.
|
||||||
|
* @returns A detached parameter record suitable for the native Host protocol.
|
||||||
|
*/
|
||||||
|
nativeParameters(params: Readonly<Record<string, unknown>>): Readonly<Record<string, unknown>> {
|
||||||
|
const visit = (value: unknown, key: string | undefined): unknown => {
|
||||||
|
if ((key === 'objectId' || key?.endsWith('ObjectId') === true) && typeof value === 'string') {
|
||||||
|
const route = this.objects.resolve(value)
|
||||||
|
if (route === undefined) return value
|
||||||
|
if (route.realm.nativeDomains.state === 'unsupported') throw new Error(route.realm.nativeDomains.reason)
|
||||||
|
return route.handle
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) return value.map(item => visit(item, undefined))
|
||||||
|
if (typeof value !== 'object' || value === null) return value
|
||||||
|
return Object.fromEntries(Object.entries(value).map(([name, item]) => [name, visit(item, name)]))
|
||||||
|
}
|
||||||
|
return visit(params, undefined) as Readonly<Record<string, unknown>>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve one realm-registry expression to a connection-local object id.
|
||||||
|
* @param source - Source generation that owns the Cordis tree node.
|
||||||
|
* @param expression - Side-effect-free realm object lookup.
|
||||||
|
* @param objectGroup - Optional DevTools retention group.
|
||||||
|
* @returns The CDP RemoteObject fields.
|
||||||
|
*/
|
||||||
|
async resolveObject(
|
||||||
|
source: InspectorSourceDescriptor,
|
||||||
|
expression: string,
|
||||||
|
objectGroup: string | undefined,
|
||||||
|
): Promise<Readonly<Record<string, unknown>>> {
|
||||||
|
const realm = this.realms.bySource(source)
|
||||||
|
if (realm === undefined) throw new Error('Cordis realm is no longer connected')
|
||||||
|
const runtime = runtimeBackend(realm)
|
||||||
|
const completion = await runtime.evaluate({
|
||||||
|
expression,
|
||||||
|
generatePreview: true,
|
||||||
|
...(objectGroup === undefined ? {} : { objectGroup }),
|
||||||
|
})
|
||||||
|
if (completion.exceptionDetails !== undefined) throw new Error('Cordis object lookup failed')
|
||||||
|
return this.objects.completion(realm, completion, objectGroup).result
|
||||||
|
}
|
||||||
|
|
||||||
|
private async enable(): Promise<object> {
|
||||||
|
this.enabled = true
|
||||||
|
try {
|
||||||
|
await Promise.all(this.realms.all().map(async (realm) => { await runtimeBackend(realm).enable() }))
|
||||||
|
for (const realm of this.realms.all()) {
|
||||||
|
this.attachConsole(realm)
|
||||||
|
this.announce(realm)
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
} catch (error) {
|
||||||
|
this.enabled = false
|
||||||
|
for (const dispose of this.consoleDisposers.values()) dispose()
|
||||||
|
this.consoleDisposers.clear()
|
||||||
|
this.announcedContexts.clear()
|
||||||
|
await Promise.allSettled(this.realms.all().map(async (realm) => { await runtimeBackend(realm).disable() }))
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async disable(): Promise<object> {
|
||||||
|
for (const dispose of this.consoleDisposers.values()) dispose()
|
||||||
|
this.consoleDisposers.clear()
|
||||||
|
try {
|
||||||
|
await Promise.all(this.realms.all().map(async (realm) => { await runtimeBackend(realm).disable() }))
|
||||||
|
} finally {
|
||||||
|
this.enabled = false
|
||||||
|
this.objects.clear()
|
||||||
|
this.announcedContexts.clear()
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async evaluate(params: Readonly<Record<string, unknown>>): Promise<object> {
|
||||||
|
const parsed = parseEvaluate(params)
|
||||||
|
const realm = this.realmFromSelector(parsed, 'contextId')
|
||||||
|
const completion = await runtimeBackend(realm).evaluate(parsed.request)
|
||||||
|
return this.objects.completion(realm, completion, parsed.request.objectGroup)
|
||||||
|
}
|
||||||
|
|
||||||
|
private getProperties(request: CdpRequest): boolean {
|
||||||
|
const objectId = request.params.objectId
|
||||||
|
if (typeof objectId !== 'string') return false
|
||||||
|
const route = this.objects.resolve(objectId)
|
||||||
|
if (route === undefined) return false
|
||||||
|
this.respond(request, async () => {
|
||||||
|
const parsed = parseGetProperties(request.params)
|
||||||
|
const properties = await runtimeBackend(route.realm).getProperties({ ...parsed.request, handle: route.handle })
|
||||||
|
return this.objects.properties(route.realm, properties, route.group)
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private callFunction(request: CdpRequest): boolean {
|
||||||
|
const objectId = typeof request.params.objectId === 'string' ? request.params.objectId : undefined
|
||||||
|
const receiver = objectId === undefined ? undefined : this.objects.resolve(objectId)
|
||||||
|
const selected = this.realmFromOptionalSelector(request.params, 'executionContextId')
|
||||||
|
if (receiver === undefined && selected === undefined && objectId !== undefined) return false
|
||||||
|
const realm = receiver?.realm ?? selected ?? this.realms.host()
|
||||||
|
if (receiver !== undefined && selected !== undefined && receiver.realm !== selected) {
|
||||||
|
this.sendError(request, 'Runtime.callFunctionOn receiver and execution context belong to different realms')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
this.respond(request, async () => {
|
||||||
|
const parsed = parseCallFunction(request.params)
|
||||||
|
const group = parsed.request.objectGroup ?? receiver?.group
|
||||||
|
const completion = await runtimeBackend(realm).callFunction({
|
||||||
|
...parsed.request,
|
||||||
|
...(receiver === undefined ? {} : { receiver: receiver.handle }),
|
||||||
|
arguments: parsed.arguments.map(argument => this.routeArgument(realm, argument)),
|
||||||
|
})
|
||||||
|
return this.objects.completion(realm, completion, group)
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private awaitPromise(request: CdpRequest): boolean {
|
||||||
|
const objectId = request.params.promiseObjectId
|
||||||
|
if (typeof objectId !== 'string') return false
|
||||||
|
const route = this.objects.resolve(objectId)
|
||||||
|
if (route === undefined) return false
|
||||||
|
this.respond(request, async () => {
|
||||||
|
const parsed = parseAwaitPromise(request.params)
|
||||||
|
const completion = await runtimeBackend(route.realm).awaitPromise({ ...parsed.request, promise: route.handle })
|
||||||
|
return this.objects.completion(route.realm, completion, route.group)
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private releaseObject(request: CdpRequest): boolean {
|
||||||
|
const objectId = request.params.objectId
|
||||||
|
if (typeof objectId !== 'string') return false
|
||||||
|
const route = this.objects.resolve(objectId)
|
||||||
|
if (route === undefined) return false
|
||||||
|
this.respond(request, async () => {
|
||||||
|
parseReleaseObject(request.params)
|
||||||
|
await runtimeBackend(route.realm).releaseObject(route.handle)
|
||||||
|
this.objects.release(objectId)
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private async releaseObjectGroup(params: Readonly<Record<string, unknown>>): Promise<object> {
|
||||||
|
const group = parseReleaseObjectGroup(params)
|
||||||
|
const realms = this.objects.realmsInGroup(group)
|
||||||
|
try {
|
||||||
|
await Promise.all(realms.map(async (realm) => { await runtimeBackend(realm).releaseObjectGroup(group) }))
|
||||||
|
} finally {
|
||||||
|
this.objects.releaseGroup(group)
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async globalLexicalScopeNames(params: Readonly<Record<string, unknown>>): Promise<object> {
|
||||||
|
const parsed = parseGlobalLexicalScopeNames(params)
|
||||||
|
const realm = this.realmFromSelector(parsed, 'executionContextId')
|
||||||
|
return { names: await runtimeBackend(realm).globalLexicalScopeNames() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async discardConsoleEntries(): Promise<object> {
|
||||||
|
await Promise.all(this.realms.all().map(async (realm) => {
|
||||||
|
if (realm.console.state === 'supported') await realm.console.backend.clear()
|
||||||
|
await runtimeBackend(realm).releaseObjectGroup('console')
|
||||||
|
}))
|
||||||
|
this.objects.releaseGroup('console')
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private realmFromSelector(
|
||||||
|
params: CdpExecutionContextSelector,
|
||||||
|
numericKey: 'contextId' | 'executionContextId',
|
||||||
|
): InspectorRealmSession {
|
||||||
|
return this.realmFromOptionalSelector(params, numericKey) ?? this.realms.host()
|
||||||
|
}
|
||||||
|
|
||||||
|
private realmFromOptionalSelector(
|
||||||
|
params: CdpExecutionContextSelector,
|
||||||
|
numericKey: 'contextId' | 'executionContextId',
|
||||||
|
): InspectorRealmSession | undefined {
|
||||||
|
const numeric = params[numericKey]
|
||||||
|
if (typeof numeric === 'number' && Number.isSafeInteger(numeric)) {
|
||||||
|
const realm = this.realms.byContextId(numeric)
|
||||||
|
if (realm !== undefined) return realm
|
||||||
|
if (numeric < 0) throw new Error('Client execution context is no longer available')
|
||||||
|
return this.realms.host()
|
||||||
|
}
|
||||||
|
const unique = params.uniqueContextId
|
||||||
|
if (typeof unique === 'string') {
|
||||||
|
const realm = this.realms.byUniqueContextId(unique)
|
||||||
|
if (realm !== undefined) return realm
|
||||||
|
if (unique.startsWith('dsh-client:')) throw new Error('Client execution context is no longer available')
|
||||||
|
return this.realms.host()
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
private routeArgument(
|
||||||
|
realm: InspectorRealmSession,
|
||||||
|
argument: CdpCallArgument,
|
||||||
|
): RuntimeCallArgument<RuntimeBackendObjectHandle> {
|
||||||
|
if (argument.kind !== 'object') return argument
|
||||||
|
const route = this.objects.resolve(argument.objectId)
|
||||||
|
if (route === undefined || route.realm !== realm) {
|
||||||
|
throw new Error('Runtime.callFunctionOn cannot pass an object between realms')
|
||||||
|
}
|
||||||
|
return { kind: 'object', handle: route.handle }
|
||||||
|
}
|
||||||
|
|
||||||
|
private unsupportedNativeRoute(params: Readonly<Record<string, unknown>>): string | undefined {
|
||||||
|
for (const key of ['contextId', 'executionContextId'] as const) {
|
||||||
|
const contextId = params[key]
|
||||||
|
if (typeof contextId !== 'number') continue
|
||||||
|
const realm = this.realms.byContextId(contextId)
|
||||||
|
if (realm?.nativeDomains.state === 'unsupported') return realm.nativeDomains.reason
|
||||||
|
if (contextId < 0 && realm === undefined) return 'Client execution context is no longer available'
|
||||||
|
}
|
||||||
|
if (typeof params.uniqueContextId === 'string') {
|
||||||
|
const realm = this.realms.byUniqueContextId(params.uniqueContextId)
|
||||||
|
if (realm?.nativeDomains.state === 'unsupported') return realm.nativeDomains.reason
|
||||||
|
if (params.uniqueContextId.startsWith('dsh-client:') && realm === undefined) {
|
||||||
|
return 'Client execution context is no longer available'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (!key.endsWith('ObjectId') && key !== 'objectId') continue
|
||||||
|
if (typeof value !== 'string') continue
|
||||||
|
const route = this.objects.resolve(value)
|
||||||
|
if (route?.realm.nativeDomains.state === 'unsupported') return route.realm.nativeDomains.reason
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
private receiveRealm(event: InspectorRealmSessionEvent): void {
|
||||||
|
if (event.type === 'opened') {
|
||||||
|
if (this.enabled) {
|
||||||
|
void runtimeBackend(event.session).enable().then(
|
||||||
|
() => {
|
||||||
|
this.attachConsole(event.session)
|
||||||
|
this.announce(event.session)
|
||||||
|
},
|
||||||
|
() => { event.session.close() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.consoleDisposers.get(event.session.descriptor.realmId)?.()
|
||||||
|
this.consoleDisposers.delete(event.session.descriptor.realmId)
|
||||||
|
this.objects.releaseRealm(event.session)
|
||||||
|
this.destroy(event.session)
|
||||||
|
}
|
||||||
|
|
||||||
|
private attachConsole(realm: InspectorRealmSession): void {
|
||||||
|
if (realm.console.state === 'unsupported' || this.consoleDisposers.has(realm.descriptor.realmId)) return
|
||||||
|
this.consoleDisposers.set(realm.descriptor.realmId, realm.console.backend.subscribe((event) => {
|
||||||
|
if (!this.enabled) return
|
||||||
|
this.transport.send(this.objects.consoleEvent(realm, event))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
private announce(realm: InspectorRealmSession): void {
|
||||||
|
if (!this.enabled || realm.context.kind !== 'synthetic' || this.announcedContexts.has(realm.context.id)) return
|
||||||
|
this.announcedContexts.add(realm.context.id)
|
||||||
|
this.transport.send({
|
||||||
|
method: 'Runtime.executionContextCreated',
|
||||||
|
params: {
|
||||||
|
context: {
|
||||||
|
id: realm.context.id,
|
||||||
|
uniqueId: realm.context.uniqueId,
|
||||||
|
origin: realm.context.origin,
|
||||||
|
name: `Client — ${realm.descriptor.label}`,
|
||||||
|
auxData: { isDefault: false, type: 'dsh-client', sourceId: realm.descriptor.sourceId },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private destroy(realm: InspectorRealmSession): void {
|
||||||
|
if (realm.context.kind !== 'synthetic' || !this.announcedContexts.delete(realm.context.id)) return
|
||||||
|
this.transport.send({
|
||||||
|
method: 'Runtime.executionContextDestroyed',
|
||||||
|
params: {
|
||||||
|
executionContextId: realm.context.id,
|
||||||
|
executionContextUniqueId: realm.context.uniqueId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private respond(request: CdpRequest, operation: () => Promise<object>): void {
|
||||||
|
respondToCdpRequest(this.transport, request, operation)
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendError(request: CdpRequest, message: string): void {
|
||||||
|
this.transport.send(cdpError(request.id, -32000, message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeBackend(realm: InspectorRealmSession): RuntimeBackend {
|
||||||
|
if (realm.runtime.state === 'unsupported') throw new Error(realm.runtime.reason)
|
||||||
|
return realm.runtime.backend
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/** Opaque identifiers owned by one Worker-side Chrome DevTools connection. */
|
||||||
|
|
||||||
|
import type { InspectorId } from '../../shared/identity.ts'
|
||||||
|
|
||||||
|
declare const cdpNumericIdBrand: unique symbol
|
||||||
|
|
||||||
|
/** Number branded with one Chrome CDP identity role. */
|
||||||
|
export type CdpNumericId<Role extends string> = number & { readonly [cdpNumericIdBrand]: Role }
|
||||||
|
|
||||||
|
/** Identity of one DevTools connection inside the Worker. */
|
||||||
|
export type InspectorConnectionId = InspectorId<'InspectorConnectionId'>
|
||||||
|
|
||||||
|
/** Runtime object id scoped to one DevTools connection. */
|
||||||
|
export type CdpRemoteObjectId = InspectorId<'CdpRemoteObjectId'>
|
||||||
|
|
||||||
|
/** Debugger script id scoped to one DevTools connection. */
|
||||||
|
export type CdpScriptId = InspectorId<'CdpScriptId'>
|
||||||
|
|
||||||
|
/** Debugger call-frame id scoped to one paused DevTools session. */
|
||||||
|
export type CdpCallFrameId = InspectorId<'CdpCallFrameId'>
|
||||||
|
|
||||||
|
/** Runtime execution-context id scoped to one DevTools target. */
|
||||||
|
export type CdpExecutionContextId = CdpNumericId<'CdpExecutionContextId'>
|
||||||
|
|
||||||
|
/** DOM frontend node id scoped to one DevTools document. */
|
||||||
|
export type CdpNodeId = CdpNumericId<'CdpNodeId'>
|
||||||
|
|
||||||
|
/** DOM backend node id stable across connection-local document projections. */
|
||||||
|
export type CdpBackendNodeId = CdpNumericId<'CdpBackendNodeId'>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and brand a string id allocated or accepted by the CDP adapter.
|
||||||
|
* @param value - CDP identifier text.
|
||||||
|
* @param label - Field named in validation failures.
|
||||||
|
* @returns The branded CDP identifier.
|
||||||
|
*/
|
||||||
|
export function cdpStringId<Role extends string>(value: string, label: string): InspectorId<Role> {
|
||||||
|
if (value.length === 0 || value.length > 16_384) {
|
||||||
|
throw new Error(`inspector CDP: ${label} must contain 1 to 16384 characters`)
|
||||||
|
}
|
||||||
|
return value as InspectorId<Role>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and brand a positive numeric id allocated by the CDP adapter.
|
||||||
|
* @param value - CDP identifier number.
|
||||||
|
* @param label - Field named in validation failures.
|
||||||
|
* @returns The branded numeric identifier.
|
||||||
|
*/
|
||||||
|
export function cdpNumericId<Role extends string>(value: number, label: string): CdpNumericId<Role> {
|
||||||
|
if (!Number.isSafeInteger(value) || value < 1) throw new Error(`inspector CDP: ${label} must be a positive integer`)
|
||||||
|
return value as CdpNumericId<Role>
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/** Minimal CDP request and transport types owned by the Worker. */
|
||||||
|
|
||||||
|
import { isPlainObject } from '../../shared/json.ts'
|
||||||
|
|
||||||
|
/** Parsed client request. */
|
||||||
|
export interface CdpRequest {
|
||||||
|
readonly id: number
|
||||||
|
readonly method: string
|
||||||
|
readonly params: Readonly<Record<string, unknown>>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Outbound CDP event. */
|
||||||
|
export interface CdpNotification {
|
||||||
|
readonly method: string
|
||||||
|
readonly params: Readonly<Record<string, unknown>>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A connected DevTools transport. */
|
||||||
|
export interface CdpTransport {
|
||||||
|
send(payload: unknown): void
|
||||||
|
close(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse one DevTools request before routing it.
|
||||||
|
* @param value - Untrusted decoded WebSocket payload.
|
||||||
|
* @returns The validated request envelope.
|
||||||
|
*/
|
||||||
|
export function parseCdpRequest(value: unknown): CdpRequest {
|
||||||
|
if (!isPlainObject(value)
|
||||||
|
|| !Number.isSafeInteger(value.id)
|
||||||
|
|| (value.id as number) < 0
|
||||||
|
|| typeof value.method !== 'string'
|
||||||
|
|| value.method.length === 0
|
||||||
|
|| (value.params !== undefined && !isPlainObject(value.params))) {
|
||||||
|
throw new Error('inspector CDP: invalid request')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: value.id as number,
|
||||||
|
method: value.method,
|
||||||
|
params: value.params ?? {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a stable CDP error response.
|
||||||
|
* @param id - Request id copied from the caller.
|
||||||
|
* @param code - JSON-RPC error code.
|
||||||
|
* @param message - Human-readable failure reason.
|
||||||
|
* @returns The CDP error envelope.
|
||||||
|
*/
|
||||||
|
export function cdpError(id: number, code: number, message: string): object {
|
||||||
|
return { id, error: { code, message } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send one failed CDP operation using the domain error code.
|
||||||
|
* @param transport - Connection receiving the response.
|
||||||
|
* @param request - Request supplying the response id.
|
||||||
|
* @param error - Rejection or synchronous error to render.
|
||||||
|
*/
|
||||||
|
export function sendCdpFailure(transport: CdpTransport, request: CdpRequest, error: unknown): void {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
transport.send(cdpError(request.id, -32000, message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settle an asynchronous CDP operation through one transport.
|
||||||
|
* @param transport - Connection receiving the response.
|
||||||
|
* @param request - Request supplying the response id.
|
||||||
|
* @param operation - Domain operation that produces the result.
|
||||||
|
*/
|
||||||
|
export function respondToCdpRequest(
|
||||||
|
transport: CdpTransport,
|
||||||
|
request: CdpRequest,
|
||||||
|
operation: () => Promise<object>,
|
||||||
|
): void {
|
||||||
|
void operation().then(
|
||||||
|
(result) => { transport.send({ id: request.id, result }) },
|
||||||
|
(error: unknown) => { sendCdpFailure(transport, request, error) },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/** Per-DevTools-connection sessions opened from the shared realm registry. */
|
||||||
|
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import { inspectorId } from '../../shared/identity.ts'
|
||||||
|
import type { InspectorRealmId } from '../../shared/cdp/ids.ts'
|
||||||
|
import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts'
|
||||||
|
import type { InspectorRealmEvent, InspectorRealmRegistry } from '../inspection/realm-store.ts'
|
||||||
|
import type { InspectorRealm, InspectorRealmSession } from '../inspection/realm.ts'
|
||||||
|
import type { InspectorConnectionId } from './ids.ts'
|
||||||
|
|
||||||
|
/** Realm-session lifecycle observed by connection-local CDP domains. */
|
||||||
|
export type InspectorRealmSessionEvent =
|
||||||
|
| { readonly type: 'opened'; readonly session: InspectorRealmSession }
|
||||||
|
| { readonly type: 'closed'; readonly session: InspectorRealmSession }
|
||||||
|
|
||||||
|
/** Owns exactly one backend session per active realm for one DevTools connection. */
|
||||||
|
export class InspectorRealmSessionSet {
|
||||||
|
/** Opaque identity shared by every domain and object table on this DevTools connection. */
|
||||||
|
readonly connectionId: InspectorConnectionId = inspectorId<'InspectorConnectionId'>(randomUUID(), 'connectionId')
|
||||||
|
private readonly sessions = new Map<InspectorRealmId, InspectorRealmSession>()
|
||||||
|
private readonly listeners = new Set<(event: InspectorRealmSessionEvent) => void>()
|
||||||
|
private readonly unsubscribeRealms: () => void
|
||||||
|
private closed = false
|
||||||
|
|
||||||
|
constructor(private readonly realms: InspectorRealmRegistry) {
|
||||||
|
for (const realm of realms.realms()) this.open(realm)
|
||||||
|
this.unsubscribeRealms = realms.subscribe((event) => { this.receiveRealm(event) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return active sessions in the registry's deterministic order.
|
||||||
|
* @returns Host followed by connected Clients.
|
||||||
|
*/
|
||||||
|
all(): InspectorRealmSession[] {
|
||||||
|
return this.realms.realms()
|
||||||
|
.map(realm => this.sessions.get(realm.descriptor.realmId))
|
||||||
|
.filter((session): session is InspectorRealmSession => session !== undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the required Host session.
|
||||||
|
* @returns The connection-local Host realm session.
|
||||||
|
*/
|
||||||
|
host(): InspectorRealmSession {
|
||||||
|
const session = this.sessions.get(this.realms.host.descriptor.realmId)
|
||||||
|
if (session === undefined) throw new Error('Host Inspector realm session is unavailable')
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve one synthetic Client context.
|
||||||
|
* @param contextId - Numeric CDP execution-context id.
|
||||||
|
* @returns Its realm session when currently connected.
|
||||||
|
*/
|
||||||
|
byContextId(contextId: number): InspectorRealmSession | undefined {
|
||||||
|
const realm = this.realms.byContextId(contextId)
|
||||||
|
return realm === undefined ? undefined : this.sessions.get(realm.descriptor.realmId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve one globally unique Client context.
|
||||||
|
* @param uniqueId - CDP unique execution-context id.
|
||||||
|
* @returns Its realm session when currently connected.
|
||||||
|
*/
|
||||||
|
byUniqueContextId(uniqueId: string): InspectorRealmSession | undefined {
|
||||||
|
const realm = this.realms.byUniqueContextId(uniqueId)
|
||||||
|
return realm === undefined ? undefined : this.sessions.get(realm.descriptor.realmId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve one active source generation to this connection's realm session.
|
||||||
|
* @param source - Source identity retained by a Cordis tree node.
|
||||||
|
* @returns The matching realm session.
|
||||||
|
*/
|
||||||
|
bySource(source: InspectorSourceDescriptor): InspectorRealmSession | undefined {
|
||||||
|
const realm = this.realms.bySource(source)
|
||||||
|
return realm === undefined ? undefined : this.sessions.get(realm.descriptor.realmId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to connection-local realm session lifecycle.
|
||||||
|
* @param listener - Session observer.
|
||||||
|
* @returns A disposer removing the observer.
|
||||||
|
*/
|
||||||
|
subscribe(listener: (event: InspectorRealmSessionEvent) => void): () => void {
|
||||||
|
this.listeners.add(listener)
|
||||||
|
return () => { this.listeners.delete(listener) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close all realm sessions and stop tracking the registry. */
|
||||||
|
close(): void {
|
||||||
|
if (this.closed) return
|
||||||
|
this.closed = true
|
||||||
|
this.unsubscribeRealms()
|
||||||
|
for (const session of this.sessions.values()) session.close()
|
||||||
|
this.sessions.clear()
|
||||||
|
this.listeners.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private receiveRealm(event: InspectorRealmEvent): void {
|
||||||
|
if (event.type === 'opened') {
|
||||||
|
const session = this.open(event.realm)
|
||||||
|
this.emit({ type: 'opened', session })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const session = this.sessions.get(event.realm.descriptor.realmId)
|
||||||
|
if (session === undefined) return
|
||||||
|
this.sessions.delete(event.realm.descriptor.realmId)
|
||||||
|
session.close()
|
||||||
|
this.emit({ type: 'closed', session })
|
||||||
|
}
|
||||||
|
|
||||||
|
private open(realm: InspectorRealm): InspectorRealmSession {
|
||||||
|
const session = realm.openSession()
|
||||||
|
this.sessions.set(realm.descriptor.realmId, session)
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(event: InspectorRealmSessionEvent): void {
|
||||||
|
for (const listener of [...this.listeners]) {
|
||||||
|
try {
|
||||||
|
listener(event)
|
||||||
|
} catch {
|
||||||
|
// One CDP domain cannot prevent sibling domains from observing realm lifecycle.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/** One DevTools connection: explicit local-domain routing plus a private Host V8 session. */
|
||||||
|
|
||||||
|
import { cdpError, parseCdpRequest, type CdpTransport } from './protocol.ts'
|
||||||
|
import { NetworkDomain, type NetworkSink } from './domains/network/session.ts'
|
||||||
|
import { CDP_METHOD_NOT_HANDLED, handleScaffold, type CdpTargetDescriptor } from './target.ts'
|
||||||
|
import { RuntimeDomainSession } from './domains/runtime/index.ts'
|
||||||
|
import { DebuggerDomainSession } from './domains/debugger/index.ts'
|
||||||
|
import { CordisDomSession, type CordisDomBackend } from './domains/dom/index.ts'
|
||||||
|
import type { InspectorSourceRegistry } from '../bridge/hub.ts'
|
||||||
|
import { HostNativeDomainSession } from './domains/native.ts'
|
||||||
|
import { InspectorRealmSessionSet } from './realm-sessions.ts'
|
||||||
|
import type { InspectorRealmRegistry } from '../inspection/realm-store.ts'
|
||||||
|
import type { CordisRuntimeTreeReader } from '../../shared/cordis/reader.ts'
|
||||||
|
|
||||||
|
/** Per-connection CDP dispatcher. */
|
||||||
|
export class CdpSession implements NetworkSink {
|
||||||
|
private readonly realms: InspectorRealmSessionSet
|
||||||
|
private readonly nativeDomains: HostNativeDomainSession
|
||||||
|
private readonly runtime: RuntimeDomainSession
|
||||||
|
private readonly debugger: DebuggerDomainSession
|
||||||
|
private readonly dom: CordisDomSession
|
||||||
|
private diagnosticsEnabled = false
|
||||||
|
private readonly unsubscribeSources: () => void
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly transport: CdpTransport,
|
||||||
|
private readonly target: CdpTargetDescriptor,
|
||||||
|
private readonly sources: InspectorSourceRegistry,
|
||||||
|
private readonly network: NetworkDomain,
|
||||||
|
realmRegistry: InspectorRealmRegistry,
|
||||||
|
domBackend: CordisDomBackend,
|
||||||
|
private readonly cordisTrees: CordisRuntimeTreeReader,
|
||||||
|
) {
|
||||||
|
this.realms = new InspectorRealmSessionSet(realmRegistry)
|
||||||
|
const native = this.realms.host().nativeDomains
|
||||||
|
if (native.state === 'unsupported') throw new Error(native.reason)
|
||||||
|
this.nativeDomains = new HostNativeDomainSession(transport, native.backend)
|
||||||
|
this.runtime = new RuntimeDomainSession(transport, this.realms)
|
||||||
|
this.debugger = new DebuggerDomainSession(transport, this.realms, this.runtime)
|
||||||
|
this.dom = new CordisDomSession(transport, domBackend, this.runtime)
|
||||||
|
this.runtime.setObjectObserver((objectId, realm, reference, group) =>
|
||||||
|
this.dom.bindObject(objectId, realm, reference, group))
|
||||||
|
this.unsubscribeSources = sources.subscribeStatus(() => {
|
||||||
|
if (this.diagnosticsEnabled) this.sendEvent('DSHInspector.sourcesChanged', { sources: this.sources.describe() })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse and dispatch one raw CDP request. Invalid frames close this client only.
|
||||||
|
* @param value - Untrusted decoded WebSocket payload.
|
||||||
|
*/
|
||||||
|
receive(value: unknown): void {
|
||||||
|
let request
|
||||||
|
try {
|
||||||
|
request = parseCdpRequest(value)
|
||||||
|
} catch {
|
||||||
|
this.transport.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (request.method === 'Runtime.releaseObject') this.dom.releaseObject(request.params.objectId)
|
||||||
|
if (request.method === 'Runtime.releaseObjectGroup') this.dom.releaseObjectGroup(request.params.objectGroup)
|
||||||
|
if (this.dom.handle(request)) return
|
||||||
|
if (this.runtime.handle(request)) return
|
||||||
|
if (this.debugger.handle(request)) return
|
||||||
|
if (this.nativeDomains.owns(request.method)) {
|
||||||
|
this.nativeDomains.handle({ ...request, params: this.runtime.nativeParameters(request.params) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let result: unknown
|
||||||
|
if (request.method.startsWith('Network.')) {
|
||||||
|
result = this.network.handle(request.method, request.params, this)
|
||||||
|
} else if (request.method === 'DSHInspector.enable') {
|
||||||
|
this.diagnosticsEnabled = true
|
||||||
|
result = { sources: this.sources.describe() }
|
||||||
|
} else if (request.method === 'DSHInspector.disable') {
|
||||||
|
this.diagnosticsEnabled = false
|
||||||
|
result = {}
|
||||||
|
} else if (request.method === 'DSHInspector.getSources') {
|
||||||
|
result = { sources: this.sources.describe() }
|
||||||
|
} else if (request.method === 'DSHInspector.getCordisTree') {
|
||||||
|
void this.cordisTrees.getTree().then(
|
||||||
|
(tree) => { this.transport.send({ id: request.id, result: { tree } }) },
|
||||||
|
(error: unknown) => {
|
||||||
|
this.transport.send(cdpError(request.id, -32000, error instanceof Error ? error.message : String(error)))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
result = handleScaffold(request, this.target)
|
||||||
|
if (result === CDP_METHOD_NOT_HANDLED) {
|
||||||
|
this.transport.send(cdpError(request.id, -32601, `Method not found: ${request.method}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.transport.send({ id: request.id, result })
|
||||||
|
} catch (error) {
|
||||||
|
this.transport.send(cdpError(request.id, -32000, error instanceof Error ? error.message : String(error)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Push one CDP event. */
|
||||||
|
sendEvent(method: string, params: Readonly<Record<string, unknown>>): void {
|
||||||
|
this.transport.send({ method, params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Release every connection-owned V8 and domain resource. */
|
||||||
|
close(): void {
|
||||||
|
this.unsubscribeSources()
|
||||||
|
this.network.detach(this)
|
||||||
|
this.dom.close()
|
||||||
|
this.runtime.close()
|
||||||
|
this.debugger.close()
|
||||||
|
this.nativeDomains.close()
|
||||||
|
this.realms.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/** Minimal page-target CDP methods required to expose Network, Console, and Sources together. */
|
||||||
|
|
||||||
|
import type { CdpRequest } from './protocol.ts'
|
||||||
|
|
||||||
|
/** Sentinel distinguishing an unowned method from an owned method returning undefined. */
|
||||||
|
export const CDP_METHOD_NOT_HANDLED = Symbol('CDP_METHOD_NOT_HANDLED')
|
||||||
|
|
||||||
|
/** Page-target identity used by discovery and scaffold responses. */
|
||||||
|
export interface CdpTargetDescriptor {
|
||||||
|
readonly targetId: string
|
||||||
|
readonly title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle one Worker-local identity or page scaffold method.
|
||||||
|
* @param request - Parsed CDP request.
|
||||||
|
* @param target - Synthetic page-target identity.
|
||||||
|
* @returns A response result or the unowned-method sentinel.
|
||||||
|
*/
|
||||||
|
export function handleScaffold(
|
||||||
|
request: CdpRequest,
|
||||||
|
target: CdpTargetDescriptor,
|
||||||
|
): object | typeof CDP_METHOD_NOT_HANDLED {
|
||||||
|
const frame = {
|
||||||
|
id: 'dsh-inspector-host-frame',
|
||||||
|
loaderId: 'dsh-inspector-loader',
|
||||||
|
url: 'dsh://host',
|
||||||
|
domainAndRegistry: '',
|
||||||
|
securityOrigin: 'dsh://host',
|
||||||
|
mimeType: 'text/html',
|
||||||
|
secureContextType: 'Secure',
|
||||||
|
crossOriginIsolatedContextType: 'NotIsolated',
|
||||||
|
gatedAPIFeatures: [],
|
||||||
|
}
|
||||||
|
switch (request.method) {
|
||||||
|
case 'Page.enable':
|
||||||
|
case 'Page.disable':
|
||||||
|
case 'Page.setLifecycleEventsEnabled':
|
||||||
|
case 'Target.setDiscoverTargets':
|
||||||
|
case 'Target.setAutoAttach':
|
||||||
|
case 'Log.enable':
|
||||||
|
case 'Log.disable':
|
||||||
|
case 'Console.enable':
|
||||||
|
case 'Console.disable':
|
||||||
|
return {}
|
||||||
|
case 'Page.getFrameTree':
|
||||||
|
return { frameTree: { frame, childFrames: [] } }
|
||||||
|
case 'Page.getResourceTree':
|
||||||
|
return { frameTree: { frame, resources: [] } }
|
||||||
|
case 'Page.getNavigationHistory':
|
||||||
|
return {
|
||||||
|
currentIndex: 0,
|
||||||
|
entries: [{ id: 1, url: frame.url, userTypedURL: frame.url, title: target.title, transitionType: 'typed' }],
|
||||||
|
}
|
||||||
|
case 'Target.getTargetInfo':
|
||||||
|
return {
|
||||||
|
targetInfo: {
|
||||||
|
targetId: target.targetId,
|
||||||
|
type: 'page',
|
||||||
|
title: target.title,
|
||||||
|
url: frame.url,
|
||||||
|
attached: true,
|
||||||
|
canAccessOpener: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
case 'Browser.getVersion':
|
||||||
|
return {
|
||||||
|
protocolVersion: '1.3',
|
||||||
|
product: 'dsh-experimental-inspector/0',
|
||||||
|
revision: '@experimental',
|
||||||
|
userAgent: 'dsh-experimental-inspector',
|
||||||
|
jsVersion: process.versions.v8,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return CDP_METHOD_NOT_HANDLED
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/** Node Worker bootstrap for the experimental Inspector. */
|
||||||
|
|
||||||
|
import { MessagePort, parentPort, workerData } from 'node:worker_threads'
|
||||||
|
import type { InspectorWorkerBoot, InspectorWorkerControl } from '../shared/bridge/messages/control.ts'
|
||||||
|
import { parseInspectorHostControl, parseInspectorWorkerConfig } from '../shared/bridge/control-codec.ts'
|
||||||
|
import { isPlainObject } from '../shared/json.ts'
|
||||||
|
import { startInspectorWorker } from './server.ts'
|
||||||
|
|
||||||
|
if (parentPort === null) throw new Error('experimental inspector: Worker entry loaded on the main thread')
|
||||||
|
const controlPort = parentPort
|
||||||
|
|
||||||
|
const bootData = workerData as unknown
|
||||||
|
if (!isPlainObject(bootData)
|
||||||
|
|| !(bootData.hostSourcePort instanceof MessagePort)) {
|
||||||
|
throw new Error('experimental inspector: invalid Worker boot data')
|
||||||
|
}
|
||||||
|
const boot: InspectorWorkerBoot<MessagePort> = {
|
||||||
|
hostSourcePort: bootData.hostSourcePort,
|
||||||
|
config: parseInspectorWorkerConfig(bootData.config),
|
||||||
|
}
|
||||||
|
|
||||||
|
let runtime: Awaited<ReturnType<typeof startInspectorWorker>> | undefined
|
||||||
|
let stopping: Promise<void> | undefined
|
||||||
|
|
||||||
|
const stop = (): Promise<void> => {
|
||||||
|
stopping ??= (async () => {
|
||||||
|
await runtime?.close()
|
||||||
|
controlPort.postMessage({ type: 'stopped' } satisfies InspectorWorkerControl)
|
||||||
|
controlPort.close()
|
||||||
|
})()
|
||||||
|
return stopping
|
||||||
|
}
|
||||||
|
|
||||||
|
controlPort.on('message', (message: unknown) => {
|
||||||
|
try {
|
||||||
|
parseInspectorHostControl(message)
|
||||||
|
void stop()
|
||||||
|
} catch (error) {
|
||||||
|
controlPort.postMessage({
|
||||||
|
type: 'failure',
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
} satisfies InspectorWorkerControl)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
runtime = await startInspectorWorker(boot)
|
||||||
|
controlPort.postMessage({ type: 'ready', ...runtime.endpoint } satisfies InspectorWorkerControl)
|
||||||
|
} catch (error) {
|
||||||
|
controlPort.postMessage({
|
||||||
|
type: 'failure',
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
} satisfies InspectorWorkerControl)
|
||||||
|
await stop()
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/** Worker-owned registry of Host and Client realm definitions. */
|
||||||
|
|
||||||
|
import type { ClientRuntimeRouter, ClientRuntimeTargetEvent } from '../bridge/runtime-rpc.ts'
|
||||||
|
import type { ClientSourceRouter } from '../bridge/source-rpc.ts'
|
||||||
|
import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts'
|
||||||
|
import { ClientInspectorRealm } from '../realms/client/index.ts'
|
||||||
|
import type { InspectorRealm } from './realm.ts'
|
||||||
|
|
||||||
|
/** Realm admission and removal observed by each DevTools connection. */
|
||||||
|
export type InspectorRealmEvent =
|
||||||
|
| { readonly type: 'opened'; readonly realm: InspectorRealm }
|
||||||
|
| { readonly type: 'closed'; readonly realm: InspectorRealm }
|
||||||
|
|
||||||
|
/** Authoritative collection of all currently executable realms. */
|
||||||
|
export class InspectorRealmRegistry {
|
||||||
|
private readonly clientsBySource = new Map<string, ClientInspectorRealm>()
|
||||||
|
private readonly listeners = new Set<(event: InspectorRealmEvent) => void>()
|
||||||
|
private readonly unsubscribeClients: () => void
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly host: InspectorRealm,
|
||||||
|
private readonly clients: ClientRuntimeRouter,
|
||||||
|
private readonly clientSources: ClientSourceRouter,
|
||||||
|
) {
|
||||||
|
for (const target of clients.targets()) this.openClient(target)
|
||||||
|
this.unsubscribeClients = clients.subscribe((event) => { this.receiveClient(event) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the realm admission order used by every connection-local session set.
|
||||||
|
* @returns Host followed by active Clients.
|
||||||
|
*/
|
||||||
|
realms(): InspectorRealm[] {
|
||||||
|
return [this.host, ...this.clientsBySource.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve one synthetic Client execution context.
|
||||||
|
* @param contextId - Numeric CDP execution-context id.
|
||||||
|
* @returns The active realm when the id belongs to a Client.
|
||||||
|
*/
|
||||||
|
byContextId(contextId: number): InspectorRealm | undefined {
|
||||||
|
for (const realm of this.clientsBySource.values()) {
|
||||||
|
if (realm.context.kind === 'synthetic' && realm.context.id === contextId) return realm
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve one globally unique Client execution context.
|
||||||
|
* @param uniqueId - CDP unique execution-context id.
|
||||||
|
* @returns The active realm when the id belongs to a Client.
|
||||||
|
*/
|
||||||
|
byUniqueContextId(uniqueId: string): InspectorRealm | undefined {
|
||||||
|
for (const realm of this.clientsBySource.values()) {
|
||||||
|
if (realm.context.kind === 'synthetic' && realm.context.uniqueId === uniqueId) return realm
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the realm for one active source generation.
|
||||||
|
* @param source - Source identity retained by a Cordis tree node.
|
||||||
|
* @returns The matching active realm.
|
||||||
|
*/
|
||||||
|
bySource(source: InspectorSourceDescriptor): InspectorRealm | undefined {
|
||||||
|
if (source.kind === 'host') return this.host
|
||||||
|
const realm = this.clientsBySource.get(source.sourceId)
|
||||||
|
return realm?.descriptor.generation === source.generation ? realm : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to Client realm admission and removal.
|
||||||
|
* @param listener - Registry observer.
|
||||||
|
* @returns A disposer removing the observer.
|
||||||
|
*/
|
||||||
|
subscribe(listener: (event: InspectorRealmEvent) => void): () => void {
|
||||||
|
this.listeners.add(listener)
|
||||||
|
return () => { this.listeners.delete(listener) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop observing Client targets and clear registry listeners. */
|
||||||
|
close(): void {
|
||||||
|
this.unsubscribeClients()
|
||||||
|
this.clientsBySource.clear()
|
||||||
|
this.listeners.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private receiveClient(event: ClientRuntimeTargetEvent): void {
|
||||||
|
if (event.type === 'opened') {
|
||||||
|
const realm = this.openClient(event.target)
|
||||||
|
this.emit({ type: 'opened', realm })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const realm = this.clientsBySource.get(event.target.source.sourceId)
|
||||||
|
if (realm === undefined || realm.target !== event.target) return
|
||||||
|
this.clientsBySource.delete(event.target.source.sourceId)
|
||||||
|
this.emit({ type: 'closed', realm })
|
||||||
|
}
|
||||||
|
|
||||||
|
private openClient(target: ClientRuntimeTargetEvent['target']): ClientInspectorRealm {
|
||||||
|
const realm = new ClientInspectorRealm(target, this.clients, this.clientSources)
|
||||||
|
this.clientsBySource.set(target.source.sourceId, realm)
|
||||||
|
return realm
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(event: InspectorRealmEvent): void {
|
||||||
|
for (const listener of [...this.listeners]) {
|
||||||
|
try {
|
||||||
|
listener(event)
|
||||||
|
} catch {
|
||||||
|
// One DevTools connection cannot disrupt realm delivery to sibling connections.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/** Worker-owned lifecycle model for active Host and Client JavaScript realms. */
|
||||||
|
|
||||||
|
import type { InspectorSourceGeneration, InspectorSourceId } from '../../shared/bridge/ids.ts'
|
||||||
|
import type { InspectorRealmCapabilities } from '../../shared/cdp/capabilities.ts'
|
||||||
|
import type { InspectorRealmId } from '../../shared/cdp/ids.ts'
|
||||||
|
import type {
|
||||||
|
ConsoleBackend,
|
||||||
|
DebuggerBackend,
|
||||||
|
NativeDomainBackend,
|
||||||
|
RealmCapability,
|
||||||
|
RuntimeBackend,
|
||||||
|
SourceBackend,
|
||||||
|
} from '../../shared/cdp/realm.ts'
|
||||||
|
|
||||||
|
/** Stable description of one active realm generation. */
|
||||||
|
export interface InspectorRealmDescriptor {
|
||||||
|
readonly realmId: InspectorRealmId
|
||||||
|
readonly sourceId: InspectorSourceId
|
||||||
|
readonly generation: InspectorSourceGeneration
|
||||||
|
readonly kind: 'host' | 'client'
|
||||||
|
readonly label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Execution-context ownership for one realm. */
|
||||||
|
export type InspectorRealmContext =
|
||||||
|
| { readonly kind: 'native' }
|
||||||
|
| {
|
||||||
|
readonly kind: 'synthetic'
|
||||||
|
readonly id: number
|
||||||
|
readonly uniqueId: string
|
||||||
|
readonly origin: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Capabilities bound to one realm and one DevTools connection. */
|
||||||
|
export interface InspectorRealmSession {
|
||||||
|
readonly descriptor: InspectorRealmDescriptor
|
||||||
|
readonly context: InspectorRealmContext
|
||||||
|
readonly runtime: RealmCapability<RuntimeBackend>
|
||||||
|
readonly console: RealmCapability<ConsoleBackend>
|
||||||
|
readonly sources: RealmCapability<SourceBackend>
|
||||||
|
readonly debugger: RealmCapability<DebuggerBackend>
|
||||||
|
readonly nativeDomains: RealmCapability<NativeDomainBackend>
|
||||||
|
/** Release every connection-owned backend resource. */
|
||||||
|
close(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Active realm that can create isolated state for each DevTools connection. */
|
||||||
|
export interface InspectorRealm {
|
||||||
|
readonly descriptor: InspectorRealmDescriptor
|
||||||
|
readonly context: InspectorRealmContext
|
||||||
|
readonly capabilities: InspectorRealmCapabilities
|
||||||
|
/** @returns Isolated backend state for one DevTools connection. */
|
||||||
|
openSession(): InspectorRealmSession
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/** Worker-side bridge dependencies for one connected Client realm. */
|
||||||
|
|
||||||
|
import type { ClientRuntimeRouter, ClientRuntimeTarget } from '../../bridge/runtime-rpc.ts'
|
||||||
|
import type { ClientSourceRouter } from '../../bridge/source-rpc.ts'
|
||||||
|
|
||||||
|
/** Typed bridge services used by all Client realm backend adapters. */
|
||||||
|
export interface ClientRealmBridge {
|
||||||
|
readonly target: ClientRuntimeTarget
|
||||||
|
readonly runtime: ClientRuntimeRouter
|
||||||
|
readonly sources: ClientSourceRouter
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bind one Client source generation to the Worker bridge services that can address it.
|
||||||
|
* @param target - Active Client source generation and execution context.
|
||||||
|
* @param runtime - Runtime and Console RPC router.
|
||||||
|
* @param sources - Source-catalog RPC router.
|
||||||
|
* @returns The immutable Client realm bridge.
|
||||||
|
*/
|
||||||
|
export function createClientRealmBridge(
|
||||||
|
target: ClientRuntimeTarget,
|
||||||
|
runtime: ClientRuntimeRouter,
|
||||||
|
sources: ClientSourceRouter,
|
||||||
|
): ClientRealmBridge {
|
||||||
|
return { target, runtime, sources }
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/** ConsoleBackend over the typed Client Console event transport. */
|
||||||
|
|
||||||
|
import type { ClientRuntimeSessionId } from '../../../shared/bridge/ids.ts'
|
||||||
|
import type { RuntimeBackendObjectHandle } from '../../../shared/cdp/ids.ts'
|
||||||
|
import type { RuntimeConsoleBackendEvent } from '../../../shared/cdp/index.ts'
|
||||||
|
import type { ClientRuntimeRouter, ClientRuntimeTarget } from '../../bridge/runtime-rpc.ts'
|
||||||
|
import type { ConsoleBackend } from '../../../shared/cdp/realm.ts'
|
||||||
|
import { clientConsoleEvent } from './values.ts'
|
||||||
|
import type { ClientScriptIdentity } from './scripts.ts'
|
||||||
|
|
||||||
|
/** Adapts session-local Client Console events to common Runtime values. */
|
||||||
|
export class ClientConsoleBackend implements ConsoleBackend {
|
||||||
|
private readonly disposers = new Set<() => void>()
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly target: ClientRuntimeTarget,
|
||||||
|
private readonly sessionId: ClientRuntimeSessionId,
|
||||||
|
private readonly router: ClientRuntimeRouter,
|
||||||
|
private readonly scriptIds: ClientScriptIdentity,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
subscribe(listener: (event: RuntimeConsoleBackendEvent<RuntimeBackendObjectHandle>) => void): () => void {
|
||||||
|
const dispose = this.router.subscribeConsole(this.target, this.sessionId, (event) => {
|
||||||
|
listener(clientConsoleEvent(event, scriptKey => this.scriptIds.toRuntime(scriptKey)))
|
||||||
|
})
|
||||||
|
this.disposers.add(dispose)
|
||||||
|
return () => {
|
||||||
|
if (!this.disposers.delete(dispose)) return
|
||||||
|
dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async clear(): Promise<void> {}
|
||||||
|
|
||||||
|
/** Disable every active Console subscription for this connection. */
|
||||||
|
close(): void {
|
||||||
|
for (const dispose of this.disposers) dispose()
|
||||||
|
this.disposers.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/** Explicit Client debugger capability until a pause-safe page agent exists. */
|
||||||
|
|
||||||
|
import type { DebuggerBackend, RealmCapability } from '../../../shared/cdp/realm.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report the unavailable Client debugger backend.
|
||||||
|
* @returns The typed unsupported result used by every Client realm session.
|
||||||
|
*/
|
||||||
|
export function clientDebuggerCapability(): RealmCapability<DebuggerBackend> {
|
||||||
|
return { state: 'unsupported', reason: 'Client native debugging is unavailable' }
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/** Client realm definition assembled from independent Runtime, Console, and Source backends. */
|
||||||
|
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import { inspectorId } from '../../../shared/identity.ts'
|
||||||
|
import { ClientConsoleBackend } from './console.ts'
|
||||||
|
import { ClientRuntimeBackend } from './runtime.ts'
|
||||||
|
import { ClientSourceBackend } from './sources.ts'
|
||||||
|
import { ClientScriptIdentity } from './scripts.ts'
|
||||||
|
import type { ClientRuntimeRouter, ClientRuntimeTarget } from '../../bridge/runtime-rpc.ts'
|
||||||
|
import type { ClientSourceRouter } from '../../bridge/source-rpc.ts'
|
||||||
|
import type { InspectorRealm, InspectorRealmDescriptor, InspectorRealmSession } from '../../inspection/realm.ts'
|
||||||
|
import { createClientRealmBridge, type ClientRealmBridge } from './bridge.ts'
|
||||||
|
import { clientDebuggerCapability } from './debugger.ts'
|
||||||
|
|
||||||
|
const CLIENT_RUNTIME_OPERATIONS = [
|
||||||
|
'evaluate',
|
||||||
|
'get-properties',
|
||||||
|
'call-function',
|
||||||
|
'await-promise',
|
||||||
|
'release-object',
|
||||||
|
'release-object-group',
|
||||||
|
'global-lexical-scope-names',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/** Active Client realm exposed through the common Worker realm model. */
|
||||||
|
export class ClientInspectorRealm implements InspectorRealm {
|
||||||
|
readonly descriptor: InspectorRealmDescriptor
|
||||||
|
readonly context: InspectorRealm['context']
|
||||||
|
readonly capabilities: InspectorRealm['capabilities']
|
||||||
|
private readonly scriptIds: ClientScriptIdentity
|
||||||
|
private readonly bridge: ClientRealmBridge
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
target: ClientRuntimeTarget,
|
||||||
|
runtimeRouter: ClientRuntimeRouter,
|
||||||
|
sourceRouter: ClientSourceRouter,
|
||||||
|
) {
|
||||||
|
this.bridge = createClientRealmBridge(target, runtimeRouter, sourceRouter)
|
||||||
|
this.descriptor = {
|
||||||
|
realmId: inspectorId<'InspectorRealmId'>(randomUUID(), 'realmId'),
|
||||||
|
sourceId: target.source.sourceId,
|
||||||
|
generation: target.source.generation,
|
||||||
|
kind: 'client',
|
||||||
|
label: target.source.label,
|
||||||
|
}
|
||||||
|
this.context = {
|
||||||
|
kind: 'synthetic',
|
||||||
|
id: target.contextId,
|
||||||
|
uniqueId: target.uniqueContextId,
|
||||||
|
origin: target.capability.origin,
|
||||||
|
}
|
||||||
|
this.scriptIds = new ClientScriptIdentity(target.contextId)
|
||||||
|
this.capabilities = {
|
||||||
|
runtime: CLIENT_RUNTIME_OPERATIONS,
|
||||||
|
console: supports(target, 'client-console') ? ['events', 'exceptions', 'clear'] : [],
|
||||||
|
sources: supports(target, 'client-sources') ? ['catalog', 'content', 'source-map'] : [],
|
||||||
|
debugger: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Active source generation represented by this realm. */
|
||||||
|
get target(): ClientRuntimeTarget {
|
||||||
|
return this.bridge.target
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open one isolated set of Client backends for a DevTools connection. */
|
||||||
|
openSession(): InspectorRealmSession {
|
||||||
|
const runtimeSessionId = inspectorId<'ClientRuntimeSessionId'>(randomUUID(), 'runtimeSessionId')
|
||||||
|
const runtime = new ClientRuntimeBackend(this.target, runtimeSessionId, this.bridge.runtime, this.scriptIds)
|
||||||
|
const console = supports(this.target, 'client-console')
|
||||||
|
? new ClientConsoleBackend(this.target, runtimeSessionId, this.bridge.runtime, this.scriptIds)
|
||||||
|
: undefined
|
||||||
|
const sources = supports(this.target, 'client-sources')
|
||||||
|
? new ClientSourceBackend(
|
||||||
|
this.target,
|
||||||
|
inspectorId<'ClientSourceSessionId'>(randomUUID(), 'sourceSessionId'),
|
||||||
|
this.bridge.sources,
|
||||||
|
this.scriptIds,
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
return {
|
||||||
|
descriptor: this.descriptor,
|
||||||
|
context: this.context,
|
||||||
|
runtime: { state: 'supported', backend: runtime },
|
||||||
|
console: console === undefined
|
||||||
|
? { state: 'unsupported', reason: 'Client source does not provide Console events' }
|
||||||
|
: { state: 'supported', backend: console },
|
||||||
|
sources: sources === undefined
|
||||||
|
? { state: 'unsupported', reason: 'Client source does not provide a script catalog' }
|
||||||
|
: { state: 'supported', backend: sources },
|
||||||
|
debugger: clientDebuggerCapability(),
|
||||||
|
nativeDomains: { state: 'unsupported', reason: 'Client realm has no native CDP transport' },
|
||||||
|
close: () => {
|
||||||
|
console?.close()
|
||||||
|
sources?.close()
|
||||||
|
runtime.close()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function supports(target: ClientRuntimeTarget, capability: 'client-console' | 'client-sources'): boolean {
|
||||||
|
return target.source.capabilities.some(candidate => candidate.type === capability)
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
/** RuntimeBackend over the typed Worker-to-Client transport. */
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ClientCallArgument,
|
||||||
|
ClientRuntimeCommand,
|
||||||
|
ClientRuntimeResult,
|
||||||
|
} from '../../../shared/bridge/messages/runtime/index.ts'
|
||||||
|
import type { ClientRuntimeSessionId } from '../../../shared/bridge/ids.ts'
|
||||||
|
import type { RuntimeBackendObjectHandle } from '../../../shared/cdp/ids.ts'
|
||||||
|
import type { RuntimeCallArgument } from '../../../shared/cdp/index.ts'
|
||||||
|
import type { ClientRuntimeRouter, ClientRuntimeTarget } from '../../bridge/runtime-rpc.ts'
|
||||||
|
import type { RuntimeBackend } from '../../../shared/cdp/realm.ts'
|
||||||
|
import {
|
||||||
|
clientCompletion,
|
||||||
|
clientException,
|
||||||
|
clientHandle,
|
||||||
|
clientInternalProperty,
|
||||||
|
clientProperty,
|
||||||
|
} from './values.ts'
|
||||||
|
import type { ClientScriptIdentity } from './scripts.ts'
|
||||||
|
|
||||||
|
/** Adapts one connection-local Client Runtime session to the common backend API. */
|
||||||
|
export class ClientRuntimeBackend implements RuntimeBackend {
|
||||||
|
private closed = false
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly target: ClientRuntimeTarget,
|
||||||
|
private readonly sessionId: ClientRuntimeSessionId,
|
||||||
|
private readonly router: ClientRuntimeRouter,
|
||||||
|
private readonly scriptIds: ClientScriptIdentity,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
enable(): Promise<void> {
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
disable(): Promise<void> {
|
||||||
|
this.router.closeTargetSession(this.target, this.sessionId)
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
async evaluate(request: Parameters<RuntimeBackend['evaluate']>[0]): ReturnType<RuntimeBackend['evaluate']> {
|
||||||
|
assertClientEvaluationOptions(request)
|
||||||
|
const { throwOnSideEffect: _throwOnSideEffect, serializationOptions: _serializationOptions, ...supported } = request
|
||||||
|
return clientCompletion(
|
||||||
|
expectResult(await this.request({ op: 'evaluate', ...supported }), 'evaluate'),
|
||||||
|
scriptKey => this.scriptIds.toRuntime(scriptKey),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProperties(request: Parameters<RuntimeBackend['getProperties']>[0]): ReturnType<RuntimeBackend['getProperties']> {
|
||||||
|
const result = expectResult(await this.request({
|
||||||
|
op: 'get-properties',
|
||||||
|
...request,
|
||||||
|
handle: clientHandle(request.handle),
|
||||||
|
}), 'get-properties')
|
||||||
|
return {
|
||||||
|
properties: result.properties.map(clientProperty),
|
||||||
|
...(result.internalProperties === undefined
|
||||||
|
? {}
|
||||||
|
: { internalProperties: result.internalProperties.map(clientInternalProperty) }),
|
||||||
|
...(result.exceptionDetails === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
exceptionDetails: clientException(
|
||||||
|
result.exceptionDetails,
|
||||||
|
scriptKey => this.scriptIds.toRuntime(scriptKey),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async callFunction(request: Parameters<RuntimeBackend['callFunction']>[0]): ReturnType<RuntimeBackend['callFunction']> {
|
||||||
|
assertClientCallOptions(request)
|
||||||
|
const {
|
||||||
|
receiver,
|
||||||
|
arguments: args,
|
||||||
|
throwOnSideEffect: _throwOnSideEffect,
|
||||||
|
serializationOptions: _serializationOptions,
|
||||||
|
...options
|
||||||
|
} = request
|
||||||
|
const command: Extract<ClientRuntimeCommand, { op: 'call-function' }> = {
|
||||||
|
op: 'call-function',
|
||||||
|
...options,
|
||||||
|
...(receiver === undefined ? {} : { receiver: clientHandle(receiver) }),
|
||||||
|
...(args === undefined ? {} : { arguments: args.map(argumentToClient) }),
|
||||||
|
}
|
||||||
|
return clientCompletion(
|
||||||
|
expectResult(await this.request(command), 'call-function'),
|
||||||
|
scriptKey => this.scriptIds.toRuntime(scriptKey),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async awaitPromise(request: Parameters<RuntimeBackend['awaitPromise']>[0]): ReturnType<RuntimeBackend['awaitPromise']> {
|
||||||
|
return clientCompletion(
|
||||||
|
expectResult(await this.request({
|
||||||
|
op: 'await-promise',
|
||||||
|
...request,
|
||||||
|
promise: clientHandle(request.promise),
|
||||||
|
}), 'await-promise'),
|
||||||
|
scriptKey => this.scriptIds.toRuntime(scriptKey),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async globalLexicalScopeNames(): Promise<readonly string[]> {
|
||||||
|
return expectResult(await this.request({ op: 'global-lexical-scope-names' }), 'global-lexical-scope-names').names
|
||||||
|
}
|
||||||
|
|
||||||
|
async releaseObject(handle: RuntimeBackendObjectHandle): Promise<void> {
|
||||||
|
expectResult(await this.request({ op: 'release-object', handle: clientHandle(handle) }), 'release-object')
|
||||||
|
}
|
||||||
|
|
||||||
|
async releaseObjectGroup(group: string): Promise<void> {
|
||||||
|
expectResult(await this.request({ op: 'release-object-group', objectGroup: group }), 'release-object-group')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close this connection's session and reject further requests. */
|
||||||
|
close(): void {
|
||||||
|
if (this.closed) return
|
||||||
|
this.closed = true
|
||||||
|
this.router.closeTargetSession(this.target, this.sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private request(command: ClientRuntimeCommand): Promise<ClientRuntimeResult> {
|
||||||
|
if (this.closed) return Promise.reject(new Error('Client realm session is closed'))
|
||||||
|
return this.router.request(this.target, this.sessionId, command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function argumentToClient(value: RuntimeCallArgument<RuntimeBackendObjectHandle>): ClientCallArgument {
|
||||||
|
return value.kind === 'object' ? { kind: 'object', handle: clientHandle(value.handle) } : value
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectResult<Operation extends ClientRuntimeResult['op']>(
|
||||||
|
result: ClientRuntimeResult,
|
||||||
|
operation: Operation,
|
||||||
|
): Extract<ClientRuntimeResult, { op: Operation }> {
|
||||||
|
if (result.op !== operation) throw new Error(`Client Runtime returned ${result.op} for ${operation}`)
|
||||||
|
return result as Extract<ClientRuntimeResult, { op: Operation }>
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertClientEvaluationOptions(request: Parameters<RuntimeBackend['evaluate']>[0]): void {
|
||||||
|
if (request.throwOnSideEffect === true) throw new Error('Client Runtime does not support throwOnSideEffect')
|
||||||
|
if (request.serializationOptions !== undefined) throw new Error('Client Runtime does not support serializationOptions')
|
||||||
|
if (request.disableBreaks === true) throw new Error('Client Runtime does not support disableBreaks')
|
||||||
|
if (request.replMode === true) throw new Error('Client Runtime does not support replMode')
|
||||||
|
if (request.userGesture === true) throw new Error('Client Runtime does not support userGesture')
|
||||||
|
if (request.allowUnsafeEvalBlockedByCSP === true) {
|
||||||
|
throw new Error('Client Runtime cannot bypass the page Content Security Policy')
|
||||||
|
}
|
||||||
|
if (request.timeoutMs !== undefined && request.awaitPromise !== true) {
|
||||||
|
throw new Error('Client Runtime supports timeout only when awaitPromise is enabled')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertClientCallOptions(request: Parameters<RuntimeBackend['callFunction']>[0]): void {
|
||||||
|
if (request.throwOnSideEffect === true) throw new Error('Client Runtime does not support throwOnSideEffect')
|
||||||
|
if (request.serializationOptions !== undefined) throw new Error('Client Runtime does not support serializationOptions')
|
||||||
|
if (request.userGesture === true) throw new Error('Client Runtime does not support userGesture')
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/** Realm-stable translation between Client catalog keys and common Runtime script keys. */
|
||||||
|
|
||||||
|
import { inspectorId } from '../../../shared/identity.ts'
|
||||||
|
import type { RuntimeScriptKey } from '../../../shared/cdp/ids.ts'
|
||||||
|
|
||||||
|
/** Allocates one shared script identity namespace for all backends in a Client realm. */
|
||||||
|
export class ClientScriptIdentity {
|
||||||
|
private readonly publicByLocal = new Map<RuntimeScriptKey, RuntimeScriptKey>()
|
||||||
|
|
||||||
|
constructor(private readonly contextId: number) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a Client-local key to the realm's public Runtime script key.
|
||||||
|
* @param localKey - Script key used on the Client wire.
|
||||||
|
* @returns Stable key shared by this realm's Runtime, Console, and Sources backends.
|
||||||
|
*/
|
||||||
|
toRuntime(localKey: RuntimeScriptKey): RuntimeScriptKey {
|
||||||
|
let scriptKey = this.publicByLocal.get(localKey)
|
||||||
|
if (scriptKey !== undefined) return scriptKey
|
||||||
|
scriptKey = inspectorId<'RuntimeScriptKey'>(
|
||||||
|
`client:${String(Math.abs(this.contextId))}:${String(this.publicByLocal.size + 1)}`,
|
||||||
|
'scriptKey',
|
||||||
|
)
|
||||||
|
this.publicByLocal.set(localKey, scriptKey)
|
||||||
|
return scriptKey
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/** Client SourceBackend over the bounded browser source-catalog transport. */
|
||||||
|
|
||||||
|
import type { ClientScriptDescriptor, ClientSourceResult } from '../../../shared/bridge/messages/sources/index.ts'
|
||||||
|
import type { ClientSourceSessionId } from '../../../shared/bridge/ids.ts'
|
||||||
|
import type { RuntimeScriptKey } from '../../../shared/cdp/ids.ts'
|
||||||
|
import type { RuntimeScript } from '../../../shared/cdp/index.ts'
|
||||||
|
import type { ClientRuntimeTarget } from '../../bridge/runtime-rpc.ts'
|
||||||
|
import type { ClientSourceRouter } from '../../bridge/source-rpc.ts'
|
||||||
|
import type { SourceBackend } from '../../../shared/cdp/realm.ts'
|
||||||
|
import type { ClientScriptIdentity } from './scripts.ts'
|
||||||
|
|
||||||
|
interface ClientScriptRoute {
|
||||||
|
readonly localKey: RuntimeScriptKey
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Presents one Client bundle catalog through the common read-only source model. */
|
||||||
|
export class ClientSourceBackend implements SourceBackend {
|
||||||
|
private readonly scripts = new Map<RuntimeScriptKey, ClientScriptRoute>()
|
||||||
|
private catalog: Promise<readonly RuntimeScript[]> | undefined
|
||||||
|
private closed = false
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly target: ClientRuntimeTarget,
|
||||||
|
private readonly sessionId: ClientSourceSessionId,
|
||||||
|
private readonly router: ClientSourceRouter,
|
||||||
|
private readonly scriptIds: ClientScriptIdentity,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async listScripts(): Promise<readonly RuntimeScript[]> {
|
||||||
|
if (this.closed) throw new Error('Client source session is closed')
|
||||||
|
this.catalog ??= this.loadCatalog()
|
||||||
|
return this.catalog
|
||||||
|
}
|
||||||
|
|
||||||
|
async getScriptSource(scriptKey: RuntimeScriptKey): Promise<string> {
|
||||||
|
const route = await this.route(scriptKey)
|
||||||
|
const source = await this.read(route.localKey, 'source')
|
||||||
|
if (source === undefined) throw new Error('Client script source is unavailable')
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSourceMap(scriptKey: RuntimeScriptKey): Promise<string | undefined> {
|
||||||
|
const route = await this.route(scriptKey)
|
||||||
|
return this.read(route.localKey, 'source-map')
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(_listener: (script: RuntimeScript) => void): () => void {
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reject pending reads owned by this DevTools connection. */
|
||||||
|
close(): void {
|
||||||
|
if (this.closed) return
|
||||||
|
this.closed = true
|
||||||
|
this.router.closeSession(this.target.source, this.sessionId)
|
||||||
|
this.scripts.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadCatalog(): Promise<readonly RuntimeScript[]> {
|
||||||
|
const result = expectResult(await this.router.request(
|
||||||
|
this.target.source,
|
||||||
|
this.sessionId,
|
||||||
|
{ op: 'list-scripts' },
|
||||||
|
), 'list-scripts')
|
||||||
|
return result.scripts.map(script => this.register(script))
|
||||||
|
}
|
||||||
|
|
||||||
|
private register(script: ClientScriptDescriptor): RuntimeScript {
|
||||||
|
const scriptKey = this.scriptIds.toRuntime(script.scriptKey)
|
||||||
|
const descriptor: RuntimeScript = {
|
||||||
|
...script,
|
||||||
|
scriptKey,
|
||||||
|
executionContextId: this.target.contextId,
|
||||||
|
}
|
||||||
|
this.scripts.set(scriptKey, { localKey: script.scriptKey })
|
||||||
|
return descriptor
|
||||||
|
}
|
||||||
|
|
||||||
|
private async route(scriptKey: RuntimeScriptKey): Promise<ClientScriptRoute> {
|
||||||
|
await this.listScripts()
|
||||||
|
const route = this.scripts.get(scriptKey)
|
||||||
|
if (route === undefined) throw new Error('Client script is no longer available')
|
||||||
|
return route
|
||||||
|
}
|
||||||
|
|
||||||
|
private async read(
|
||||||
|
scriptKey: RuntimeScriptKey,
|
||||||
|
content: 'source' | 'source-map',
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const chunks: Uint8Array[] = []
|
||||||
|
let offset = 0
|
||||||
|
while (true) {
|
||||||
|
const result = expectResult(await this.router.request(this.target.source, this.sessionId, {
|
||||||
|
op: 'get-content-chunk',
|
||||||
|
scriptKey,
|
||||||
|
content,
|
||||||
|
offset,
|
||||||
|
maxBytes: this.router.chunkBytes,
|
||||||
|
}), 'get-content-chunk')
|
||||||
|
if (!result.available) return undefined
|
||||||
|
const bytes = Buffer.from(result.data, 'base64')
|
||||||
|
if (bytes.byteLength > this.router.chunkBytes
|
||||||
|
|| result.nextOffset !== offset + bytes.byteLength
|
||||||
|
|| (!result.eof && result.nextOffset === offset)
|
||||||
|
|| result.nextOffset > this.router.maxContentBytes) {
|
||||||
|
throw new Error('Client source returned an invalid content chunk')
|
||||||
|
}
|
||||||
|
chunks.push(bytes)
|
||||||
|
offset = result.nextOffset
|
||||||
|
if (result.eof) break
|
||||||
|
}
|
||||||
|
return new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectResult<Operation extends ClientSourceResult['op']>(
|
||||||
|
result: ClientSourceResult,
|
||||||
|
operation: Operation,
|
||||||
|
): Extract<ClientSourceResult, { op: Operation }> {
|
||||||
|
if (result.op !== operation) throw new Error(`Client source returned ${result.op} for ${operation}`)
|
||||||
|
return result as Extract<ClientSourceResult, { op: Operation }>
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
/** Conversion from Client wire values to realm-neutral Runtime values. */
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ClientRuntimeExceptionDetails,
|
||||||
|
ClientRuntimePropertyDescriptor,
|
||||||
|
ClientRuntimeRemoteObject,
|
||||||
|
ClientRuntimeResult,
|
||||||
|
} from '../../../shared/bridge/messages/runtime/index.ts'
|
||||||
|
import {
|
||||||
|
type ClientRemoteObjectHandle,
|
||||||
|
} from '../../../shared/bridge/ids.ts'
|
||||||
|
import { inspectorId } from '../../../shared/identity.ts'
|
||||||
|
import type { RuntimeBackendObjectHandle, RuntimeScriptKey } from '../../../shared/cdp/ids.ts'
|
||||||
|
import type {
|
||||||
|
RuntimeCompletion,
|
||||||
|
RuntimeConsoleBackendEvent,
|
||||||
|
RuntimeExceptionDetails,
|
||||||
|
RuntimeInternalPropertyDescriptor,
|
||||||
|
RuntimePropertyDescriptor,
|
||||||
|
RuntimeRemoteObject,
|
||||||
|
RuntimeStackTrace,
|
||||||
|
} from '../../../shared/cdp/index.ts'
|
||||||
|
|
||||||
|
/** Maps a Client-local script key into its realm-wide Runtime identity. */
|
||||||
|
export type ClientScriptKeyMapper = (scriptKey: RuntimeScriptKey) => RuntimeScriptKey
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert one Client completion and all nested objects.
|
||||||
|
* @param result - Successful Client Runtime command result.
|
||||||
|
* @param mapScriptKey - Realm-wide script identity mapper.
|
||||||
|
* @returns A realm-neutral Runtime completion.
|
||||||
|
*/
|
||||||
|
export function clientCompletion(
|
||||||
|
result: Extract<ClientRuntimeResult, { op: 'evaluate' | 'call-function' | 'await-promise' }>,
|
||||||
|
mapScriptKey: ClientScriptKeyMapper,
|
||||||
|
): RuntimeCompletion<RuntimeBackendObjectHandle> {
|
||||||
|
return {
|
||||||
|
result: clientRemoteObject(result.completion.result),
|
||||||
|
...(result.completion.exceptionDetails === undefined
|
||||||
|
? {}
|
||||||
|
: { exceptionDetails: clientException(result.completion.exceptionDetails, mapScriptKey) }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert one Client property descriptor and all nested objects.
|
||||||
|
* @param value - Client wire property descriptor.
|
||||||
|
* @returns A realm-neutral property descriptor.
|
||||||
|
*/
|
||||||
|
export function clientProperty(
|
||||||
|
value: ClientRuntimePropertyDescriptor,
|
||||||
|
): RuntimePropertyDescriptor<RuntimeBackendObjectHandle> {
|
||||||
|
const { value: propertyValue, get, set, symbol, ...descriptor } = value
|
||||||
|
return {
|
||||||
|
...descriptor,
|
||||||
|
...(propertyValue === undefined ? {} : { value: clientRemoteObject(propertyValue) }),
|
||||||
|
...(get === undefined ? {} : { get: clientRemoteObject(get) }),
|
||||||
|
...(set === undefined ? {} : { set: clientRemoteObject(set) }),
|
||||||
|
...(symbol === undefined ? {} : { symbol: clientRemoteObject(symbol) }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert one Client internal property descriptor.
|
||||||
|
* @param value - Client wire internal property.
|
||||||
|
* @returns A realm-neutral internal property.
|
||||||
|
*/
|
||||||
|
export function clientInternalProperty(
|
||||||
|
value: RuntimeInternalPropertyDescriptor<ClientRemoteObjectHandle>,
|
||||||
|
): RuntimeInternalPropertyDescriptor<RuntimeBackendObjectHandle> {
|
||||||
|
return {
|
||||||
|
name: value.name,
|
||||||
|
...(value.value === undefined ? {} : { value: clientRemoteObject(value.value) }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Client exception details and their optional object.
|
||||||
|
* @param value - Client wire exception details.
|
||||||
|
* @param mapScriptKey - Realm-wide script identity mapper.
|
||||||
|
* @returns Realm-neutral exception details.
|
||||||
|
*/
|
||||||
|
export function clientException(
|
||||||
|
value: ClientRuntimeExceptionDetails,
|
||||||
|
mapScriptKey: ClientScriptKeyMapper,
|
||||||
|
): RuntimeExceptionDetails<RuntimeBackendObjectHandle> {
|
||||||
|
const { exception, ...details } = value
|
||||||
|
return {
|
||||||
|
...details,
|
||||||
|
...(value.stackTrace === undefined ? {} : { stackTrace: clientStackTrace(value.stackTrace, mapScriptKey) }),
|
||||||
|
...(exception === undefined ? {} : { exception: clientRemoteObject(exception) }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a Client Console event recursively.
|
||||||
|
* @param value - Client wire Console event.
|
||||||
|
* @param mapScriptKey - Realm-wide script identity mapper.
|
||||||
|
* @returns A realm-neutral Console event.
|
||||||
|
*/
|
||||||
|
export function clientConsoleEvent(
|
||||||
|
value: RuntimeConsoleBackendEvent<ClientRemoteObjectHandle>,
|
||||||
|
mapScriptKey: ClientScriptKeyMapper,
|
||||||
|
): RuntimeConsoleBackendEvent<RuntimeBackendObjectHandle> {
|
||||||
|
if (value.type === 'console-api') {
|
||||||
|
return {
|
||||||
|
type: value.type,
|
||||||
|
event: {
|
||||||
|
...value.event,
|
||||||
|
arguments: value.event.arguments.map(clientRemoteObject),
|
||||||
|
...(value.event.stackTrace === undefined
|
||||||
|
? {}
|
||||||
|
: { stackTrace: clientStackTrace(value.event.stackTrace, mapScriptKey) }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: value.type,
|
||||||
|
event: { ...value.event, details: clientException(value.event.details, mapScriptKey) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a Client RemoteObject into the backend-neutral handle slot.
|
||||||
|
* @param value - Client wire RemoteObject.
|
||||||
|
* @returns A realm-neutral Runtime value.
|
||||||
|
*/
|
||||||
|
export function clientRemoteObject(
|
||||||
|
value: ClientRuntimeRemoteObject,
|
||||||
|
): RuntimeRemoteObject<RuntimeBackendObjectHandle> {
|
||||||
|
return {
|
||||||
|
descriptor: value.descriptor,
|
||||||
|
...(value.object === undefined
|
||||||
|
? {}
|
||||||
|
: { object: { handle: backendHandle(value.object.handle) } }),
|
||||||
|
...(value.semanticReference === undefined ? {} : { semanticReference: value.semanticReference }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebrand a common backend handle for the Client transport that owns it.
|
||||||
|
* @param value - Backend handle from a routed Runtime request.
|
||||||
|
* @returns The same opaque text under its Client wire role.
|
||||||
|
*/
|
||||||
|
export function clientHandle(value: string): ClientRemoteObjectHandle {
|
||||||
|
return inspectorId<'ClientRemoteObjectHandle'>(value, 'Client object handle')
|
||||||
|
}
|
||||||
|
|
||||||
|
function backendHandle(value: string): RuntimeBackendObjectHandle {
|
||||||
|
return inspectorId<'RuntimeBackendObjectHandle'>(value, 'Runtime backend object handle')
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientStackTrace(value: RuntimeStackTrace, mapScriptKey: ClientScriptKeyMapper): RuntimeStackTrace {
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
callFrames: value.callFrames.map(frame => ({
|
||||||
|
...frame,
|
||||||
|
...(frame.scriptKey === undefined ? {} : { scriptKey: mapScriptKey(frame.scriptKey) }),
|
||||||
|
})),
|
||||||
|
...(value.parent === undefined ? {} : { parent: clientStackTrace(value.parent, mapScriptKey) }),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
/** Per-DevTools-connection bridge to the Host main thread's real V8 inspector target. */
|
||||||
|
|
||||||
|
import { Session } from 'node:inspector'
|
||||||
|
import type { NativeProtocolNotification } from '../../../shared/cdp/realm.ts'
|
||||||
|
|
||||||
|
/** Notification emitted by Node's native inspector session. */
|
||||||
|
export type HostInspectorNotification = NativeProtocolNotification
|
||||||
|
|
||||||
|
interface DynamicInspectorSession {
|
||||||
|
connectToMainThread(): void
|
||||||
|
disconnect(): void
|
||||||
|
on(event: 'inspectorNotification', listener: (message: HostInspectorNotification) => void): this
|
||||||
|
post(
|
||||||
|
method: string,
|
||||||
|
params: Readonly<Record<string, unknown>> | undefined,
|
||||||
|
callback: (error: Error | null, result?: Readonly<Record<string, unknown>>) => void,
|
||||||
|
): void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Connection-local carrier for requests and notifications from the Host V8 inspector. */
|
||||||
|
export class HostInspectorSession {
|
||||||
|
private readonly session = new Session() as unknown as DynamicInspectorSession
|
||||||
|
private readonly listeners = new Set<(message: HostInspectorNotification) => void>()
|
||||||
|
private connected = false
|
||||||
|
private failure: string | undefined
|
||||||
|
|
||||||
|
constructor(private readonly contextName: string) {
|
||||||
|
this.session.on('inspectorNotification', (message) => {
|
||||||
|
const rewritten = this.rewriteContextName(message)
|
||||||
|
for (const listener of [...this.listeners]) {
|
||||||
|
try {
|
||||||
|
listener(rewritten)
|
||||||
|
} catch {
|
||||||
|
// One domain subscriber cannot starve notifications for sibling domains.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to native inspector notifications.
|
||||||
|
* @param listener - Consumer owned by one Worker domain adapter.
|
||||||
|
* @returns A disposer removing the consumer.
|
||||||
|
*/
|
||||||
|
subscribe(listener: (message: HostInspectorNotification) => void): () => void {
|
||||||
|
this.listeners.add(listener)
|
||||||
|
return () => { this.listeners.delete(listener) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute one Host V8 request for a Worker-owned composite Runtime operation.
|
||||||
|
* @param method - CDP method name.
|
||||||
|
* @param params - Validated request parameters.
|
||||||
|
* @returns The Host inspector result.
|
||||||
|
*/
|
||||||
|
request(method: string, params: Readonly<Record<string, unknown>>): Promise<Readonly<Record<string, unknown>>> {
|
||||||
|
const failure = this.connect()
|
||||||
|
if (failure !== undefined) return Promise.reject(new Error(failure))
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
this.session.post(method, params, (error, result) => {
|
||||||
|
if (error !== null) reject(error)
|
||||||
|
else resolve(result ?? {})
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
reject(new Error(renderError(error)))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Disconnect this DevTools client's V8 session. */
|
||||||
|
close(): void {
|
||||||
|
this.listeners.clear()
|
||||||
|
if (!this.connected || this.failure !== undefined) return
|
||||||
|
this.connected = false
|
||||||
|
try {
|
||||||
|
this.session.disconnect()
|
||||||
|
} catch {
|
||||||
|
// The underlying inspector session is already disconnected.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private connect(): string | undefined {
|
||||||
|
if (this.connected) return this.failure
|
||||||
|
this.connected = true
|
||||||
|
try {
|
||||||
|
this.session.connectToMainThread()
|
||||||
|
} catch (error) {
|
||||||
|
this.failure = `Host V8 inspector is unavailable: ${renderError(error)}`
|
||||||
|
}
|
||||||
|
return this.failure
|
||||||
|
}
|
||||||
|
|
||||||
|
private rewriteContextName(message: HostInspectorNotification): HostInspectorNotification {
|
||||||
|
if (message.method !== 'Runtime.executionContextCreated') return message
|
||||||
|
const params = message.params
|
||||||
|
const context = params?.context
|
||||||
|
if (typeof context !== 'object' || context === null) return message
|
||||||
|
const record = context as Readonly<Record<string, unknown>>
|
||||||
|
const auxData = record.auxData
|
||||||
|
if (typeof auxData !== 'object' || auxData === null || (auxData as Readonly<Record<string, unknown>>).isDefault !== true) {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
method: message.method,
|
||||||
|
params: {
|
||||||
|
...params,
|
||||||
|
context: { ...record, name: this.contextName },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serializes accepted native notifications and isolates sibling consumers. */
|
||||||
|
export class HostNotificationChannel<Event> {
|
||||||
|
private readonly listeners = new Set<(event: Event) => void>()
|
||||||
|
private readonly unsubscribe: () => void
|
||||||
|
private delivery = Promise.resolve()
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
target: HostInspectorSession,
|
||||||
|
private readonly accepts: (message: HostInspectorNotification) => boolean,
|
||||||
|
private readonly project: (message: HostInspectorNotification) => Promise<Event | undefined>,
|
||||||
|
) {
|
||||||
|
this.unsubscribe = target.subscribe((message) => { this.receive(message) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to projected native notifications.
|
||||||
|
* @param listener - Consumer invoked in subscription order.
|
||||||
|
* @returns A disposer removing the consumer.
|
||||||
|
*/
|
||||||
|
subscribe(listener: (event: Event) => void): () => void {
|
||||||
|
this.listeners.add(listener)
|
||||||
|
return () => { this.listeners.delete(listener) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Release the native notification subscription and all consumers. */
|
||||||
|
close(): void {
|
||||||
|
this.unsubscribe()
|
||||||
|
this.listeners.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private receive(message: HostInspectorNotification): void {
|
||||||
|
if (!this.accepts(message)) return
|
||||||
|
this.delivery = this.delivery.then(async () => {
|
||||||
|
const event = await this.project(message)
|
||||||
|
if (event === undefined) return
|
||||||
|
for (const listener of [...this.listeners]) {
|
||||||
|
try {
|
||||||
|
listener(event)
|
||||||
|
} catch {
|
||||||
|
// One notification consumer cannot prevent delivery to its siblings.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
// Malformed optional native notifications do not interrupt request handling.
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderError(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error)
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/** ConsoleBackend implementation over native Node Runtime notifications. */
|
||||||
|
|
||||||
|
import type { RuntimeBackendObjectHandle } from '../../../shared/cdp/ids.ts'
|
||||||
|
import type {
|
||||||
|
RuntimeConsoleBackendEvent,
|
||||||
|
RuntimeConsoleType,
|
||||||
|
} from '../../../shared/cdp/index.ts'
|
||||||
|
import type { HostInspectorSession } from './bridge.ts'
|
||||||
|
import type { ConsoleBackend } from '../../../shared/cdp/realm.ts'
|
||||||
|
import { isNativeRecord } from './values.ts'
|
||||||
|
import { HostNotificationChannel } from './bridge.ts'
|
||||||
|
import type { HostRuntimeBackend } from './runtime.ts'
|
||||||
|
|
||||||
|
const CONSOLE_TYPES = new Set<RuntimeConsoleType>([
|
||||||
|
'log', 'debug', 'info', 'error', 'warning', 'dir', 'dirxml', 'table', 'trace', 'clear',
|
||||||
|
'startGroup', 'startGroupCollapsed', 'endGroup', 'assert', 'profile', 'profileEnd', 'count', 'timeEnd',
|
||||||
|
])
|
||||||
|
|
||||||
|
/** Converts native Runtime notifications to realm-neutral Console events. */
|
||||||
|
export class HostConsoleBackend implements ConsoleBackend {
|
||||||
|
private readonly events: HostNotificationChannel<RuntimeConsoleBackendEvent<RuntimeBackendObjectHandle>>
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly target: HostInspectorSession,
|
||||||
|
private readonly runtime: HostRuntimeBackend,
|
||||||
|
) {
|
||||||
|
this.events = new HostNotificationChannel(
|
||||||
|
target,
|
||||||
|
message => message.method === 'Runtime.consoleAPICalled' || message.method === 'Runtime.exceptionThrown',
|
||||||
|
async message => message.method === 'Runtime.consoleAPICalled'
|
||||||
|
? this.consoleEvent(message.params)
|
||||||
|
: this.exceptionEvent(message.params),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to native Console and exception events.
|
||||||
|
* @param listener - Connection-local event consumer.
|
||||||
|
* @returns A disposer removing the consumer.
|
||||||
|
*/
|
||||||
|
subscribe(listener: (event: RuntimeConsoleBackendEvent<RuntimeBackendObjectHandle>) => void): () => void {
|
||||||
|
return this.events.subscribe(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
async clear(): Promise<void> {
|
||||||
|
await this.target.request('Runtime.discardConsoleEntries', {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Release the native notification subscription. */
|
||||||
|
close(): void {
|
||||||
|
this.events.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private async consoleEvent(
|
||||||
|
params: Readonly<Record<string, unknown>> | undefined,
|
||||||
|
): Promise<RuntimeConsoleBackendEvent<RuntimeBackendObjectHandle> | undefined> {
|
||||||
|
const type = params?.type
|
||||||
|
const args = params?.args
|
||||||
|
const timestamp = params?.timestamp
|
||||||
|
const stackTrace = params?.stackTrace
|
||||||
|
if (!CONSOLE_TYPES.has(type as RuntimeConsoleType) || !Array.isArray(args) || typeof timestamp !== 'number') return undefined
|
||||||
|
return {
|
||||||
|
type: 'console-api',
|
||||||
|
event: {
|
||||||
|
type: type as RuntimeConsoleType,
|
||||||
|
arguments: await Promise.all(args.map(value => this.runtime.remoteObject(value))),
|
||||||
|
timestamp,
|
||||||
|
...(typeof params?.executionContextId === 'number' ? { contextId: params.executionContextId } : {}),
|
||||||
|
...(isNativeRecord(stackTrace) ? { stackTrace: this.runtime.stackTrace(stackTrace) } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async exceptionEvent(
|
||||||
|
params: Readonly<Record<string, unknown>> | undefined,
|
||||||
|
): Promise<RuntimeConsoleBackendEvent<RuntimeBackendObjectHandle> | undefined> {
|
||||||
|
const timestamp = params?.timestamp
|
||||||
|
const exceptionDetails = params?.exceptionDetails
|
||||||
|
const contextId = params?.executionContextId
|
||||||
|
if (typeof timestamp !== 'number' || exceptionDetails === undefined) return undefined
|
||||||
|
return {
|
||||||
|
type: 'exception',
|
||||||
|
event: {
|
||||||
|
timestamp,
|
||||||
|
...(typeof contextId === 'number' ? { contextId } : {}),
|
||||||
|
details: await this.runtime.exceptionDetails(exceptionDetails),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/** DebuggerBackend implementation over one native Node inspector session. */
|
||||||
|
|
||||||
|
import type { RuntimeBackendObjectHandle } from '../../../shared/cdp/ids.ts'
|
||||||
|
import { isJsonValue } from '../../../shared/json.ts'
|
||||||
|
import type {
|
||||||
|
RuntimeDebuggerCallFrame,
|
||||||
|
RuntimeDebuggerEvent,
|
||||||
|
RuntimeDebuggerLocation,
|
||||||
|
RuntimeDebuggerScope,
|
||||||
|
} from '../../../shared/cdp/index.ts'
|
||||||
|
import type { DebuggerBackend } from '../../../shared/cdp/realm.ts'
|
||||||
|
import type { HostInspectorSession } from './bridge.ts'
|
||||||
|
import { optionalNativeField, requireNativeRecord } from './values.ts'
|
||||||
|
import { HostNotificationChannel } from './bridge.ts'
|
||||||
|
import type { HostRuntimeBackend } from './runtime.ts'
|
||||||
|
import { hostScriptKey } from './scripts.ts'
|
||||||
|
|
||||||
|
/** Native Host debugger adapted to common commands, Runtime values, and events. */
|
||||||
|
export class HostDebuggerBackend implements DebuggerBackend {
|
||||||
|
private readonly events: HostNotificationChannel<RuntimeDebuggerEvent<RuntimeBackendObjectHandle>>
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly target: HostInspectorSession,
|
||||||
|
private readonly runtime: HostRuntimeBackend,
|
||||||
|
) {
|
||||||
|
this.events = new HostNotificationChannel(
|
||||||
|
target,
|
||||||
|
message => message.method === 'Debugger.resumed'
|
||||||
|
|| message.method === 'Debugger.breakpointResolved'
|
||||||
|
|| message.method === 'Debugger.paused',
|
||||||
|
async message => message.method === 'Debugger.resumed'
|
||||||
|
? { type: 'resumed' }
|
||||||
|
: message.method === 'Debugger.breakpointResolved'
|
||||||
|
? breakpointResolved(message.params)
|
||||||
|
: this.paused(message.params),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async enable(request: Parameters<DebuggerBackend['enable']>[0]): Promise<Readonly<Record<string, unknown>>> {
|
||||||
|
return this.target.request('Debugger.enable', {
|
||||||
|
...optionalNativeField('maxScriptsCacheSize', request.maxScriptsCacheSize),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async disable(): Promise<Readonly<Record<string, unknown>>> {
|
||||||
|
return this.target.request('Debugger.disable', {})
|
||||||
|
}
|
||||||
|
|
||||||
|
async pause(): Promise<Readonly<Record<string, unknown>>> {
|
||||||
|
return this.target.request('Debugger.pause', {})
|
||||||
|
}
|
||||||
|
|
||||||
|
async resume(request: Parameters<DebuggerBackend['resume']>[0]): Promise<Readonly<Record<string, unknown>>> {
|
||||||
|
return this.target.request('Debugger.resume', {
|
||||||
|
...optionalNativeField('terminateOnResume', request.terminateOnResume),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async evaluateOnCallFrame(
|
||||||
|
request: Parameters<DebuggerBackend['evaluateOnCallFrame']>[0],
|
||||||
|
): ReturnType<DebuggerBackend['evaluateOnCallFrame']> {
|
||||||
|
return this.runtime.completion(await this.target.request('Debugger.evaluateOnCallFrame', {
|
||||||
|
callFrameId: request.callFrameId,
|
||||||
|
expression: request.expression,
|
||||||
|
...optionalNativeField('objectGroup', request.objectGroup),
|
||||||
|
...optionalNativeField('includeCommandLineAPI', request.includeCommandLineAPI),
|
||||||
|
...optionalNativeField('silent', request.silent),
|
||||||
|
...optionalNativeField('returnByValue', request.returnByValue),
|
||||||
|
...optionalNativeField('generatePreview', request.generatePreview),
|
||||||
|
...optionalNativeField('throwOnSideEffect', request.throwOnSideEffect),
|
||||||
|
...optionalNativeField('timeout', request.timeoutMs),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(listener: (event: RuntimeDebuggerEvent<RuntimeBackendObjectHandle>) => void): () => void {
|
||||||
|
return this.events.subscribe(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Release the native notification subscription. */
|
||||||
|
close(): void {
|
||||||
|
this.events.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private async paused(
|
||||||
|
params: Readonly<Record<string, unknown>> | undefined,
|
||||||
|
): Promise<RuntimeDebuggerEvent<RuntimeBackendObjectHandle> | undefined> {
|
||||||
|
if (!Array.isArray(params?.callFrames) || typeof params.reason !== 'string') return undefined
|
||||||
|
const callFrames = await Promise.all(params.callFrames.map(async frame => this.callFrame(frame)))
|
||||||
|
const data = params.data
|
||||||
|
const hitBreakpoints = params.hitBreakpoints
|
||||||
|
return {
|
||||||
|
type: 'paused',
|
||||||
|
callFrames,
|
||||||
|
reason: params.reason,
|
||||||
|
...(data === undefined || !isJsonValue(data) ? {} : { data }),
|
||||||
|
...(isStringArray(hitBreakpoints)
|
||||||
|
? { hitBreakpoints: hitBreakpoints }
|
||||||
|
: {}),
|
||||||
|
...(params.asyncStackTrace === undefined
|
||||||
|
? {}
|
||||||
|
: { asyncStackTrace: this.runtime.stackTrace(params.asyncStackTrace) }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async callFrame(value: unknown): Promise<RuntimeDebuggerCallFrame<RuntimeBackendObjectHandle>> {
|
||||||
|
const record = requireNativeRecord(value, 'Host Debugger call frame')
|
||||||
|
if (typeof record.callFrameId !== 'string'
|
||||||
|
|| typeof record.functionName !== 'string'
|
||||||
|
|| typeof record.url !== 'string'
|
||||||
|
|| !Array.isArray(record.scopeChain)) {
|
||||||
|
throw new Error('Host Debugger returned an invalid call frame')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
callFrameId: record.callFrameId,
|
||||||
|
functionName: record.functionName,
|
||||||
|
...(record.functionLocation === undefined ? {} : { functionLocation: location(record.functionLocation) }),
|
||||||
|
location: location(record.location),
|
||||||
|
url: record.url,
|
||||||
|
scopeChain: await Promise.all(record.scopeChain.map(async scope => this.scope(scope))),
|
||||||
|
thisObject: await this.runtime.remoteObject(record.this),
|
||||||
|
...(record.returnValue === undefined ? {} : { returnValue: await this.runtime.remoteObject(record.returnValue) }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async scope(value: unknown): Promise<RuntimeDebuggerScope<RuntimeBackendObjectHandle>> {
|
||||||
|
const record = requireNativeRecord(value, 'Host Debugger scope')
|
||||||
|
if (typeof record.type !== 'string') throw new Error('Host Debugger returned an invalid scope')
|
||||||
|
return {
|
||||||
|
type: record.type,
|
||||||
|
object: await this.runtime.remoteObject(record.object),
|
||||||
|
...(typeof record.name === 'string' ? { name: record.name } : {}),
|
||||||
|
...(record.startLocation === undefined ? {} : { startLocation: location(record.startLocation) }),
|
||||||
|
...(record.endLocation === undefined ? {} : { endLocation: location(record.endLocation) }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function breakpointResolved(
|
||||||
|
params: Readonly<Record<string, unknown>> | undefined,
|
||||||
|
): Extract<RuntimeDebuggerEvent<RuntimeBackendObjectHandle>, { type: 'breakpoint-resolved' }> | undefined {
|
||||||
|
if (typeof params?.breakpointId !== 'string' || params.location === undefined) return undefined
|
||||||
|
return {
|
||||||
|
type: 'breakpoint-resolved',
|
||||||
|
breakpointId: params.breakpointId,
|
||||||
|
location: location(params.location),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function location(value: unknown): RuntimeDebuggerLocation {
|
||||||
|
const record = requireNativeRecord(value, 'Host Debugger location')
|
||||||
|
if (typeof record.scriptId !== 'string' || !Number.isSafeInteger(record.lineNumber)) {
|
||||||
|
throw new Error('Host Debugger returned an invalid location')
|
||||||
|
}
|
||||||
|
if (record.columnNumber !== undefined && !Number.isSafeInteger(record.columnNumber)) {
|
||||||
|
throw new Error('Host Debugger returned an invalid location column')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
scriptKey: hostScriptKey(record.scriptId),
|
||||||
|
lineNumber: record.lineNumber as number,
|
||||||
|
...(record.columnNumber === undefined ? {} : { columnNumber: record.columnNumber as number }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStringArray(value: unknown): value is string[] {
|
||||||
|
return Array.isArray(value) && value.every(item => typeof item === 'string')
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/** Host realm adapter backed by a connection-local Node inspector session. */
|
||||||
|
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import { inspectorId } from '../../../shared/identity.ts'
|
||||||
|
import { HostConsoleBackend } from './console.ts'
|
||||||
|
import { HostDebuggerBackend } from './debugger.ts'
|
||||||
|
import { HostRuntimeBackend } from './runtime.ts'
|
||||||
|
import { HostSourceBackend } from './sources.ts'
|
||||||
|
import { HostInspectorSession } from './bridge.ts'
|
||||||
|
import type { InspectorRealm, InspectorRealmDescriptor, InspectorRealmSession } from '../../inspection/realm.ts'
|
||||||
|
|
||||||
|
const HOST_RUNTIME_OPERATIONS = [
|
||||||
|
'evaluate',
|
||||||
|
'get-properties',
|
||||||
|
'call-function',
|
||||||
|
'await-promise',
|
||||||
|
'release-object',
|
||||||
|
'release-object-group',
|
||||||
|
'global-lexical-scope-names',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/** Host realm definition that opens one native V8 session per DevTools connection. */
|
||||||
|
export class HostInspectorRealm implements InspectorRealm {
|
||||||
|
readonly descriptor: InspectorRealmDescriptor
|
||||||
|
readonly context: InspectorRealm['context'] = { kind: 'native' }
|
||||||
|
readonly capabilities: InspectorRealm['capabilities'] = {
|
||||||
|
runtime: HOST_RUNTIME_OPERATIONS,
|
||||||
|
console: ['events', 'exceptions', 'clear'],
|
||||||
|
sources: ['catalog', 'content', 'source-map'],
|
||||||
|
debugger: ['breakpoint', 'pause', 'resume', 'step', 'call-frame'],
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(private readonly label: string) {
|
||||||
|
this.descriptor = {
|
||||||
|
realmId: inspectorId<'InspectorRealmId'>(randomUUID(), 'realmId'),
|
||||||
|
sourceId: inspectorId<'InspectorSourceId'>('host-runtime', 'sourceId'),
|
||||||
|
generation: inspectorId<'InspectorSourceGeneration'>(randomUUID(), 'generation'),
|
||||||
|
kind: 'host',
|
||||||
|
label,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open a native Host inspector session for one DevTools connection. */
|
||||||
|
openSession(): InspectorRealmSession {
|
||||||
|
const target = new HostInspectorSession(this.label)
|
||||||
|
const runtime = new HostRuntimeBackend(target)
|
||||||
|
const console = new HostConsoleBackend(target, runtime)
|
||||||
|
const sources = new HostSourceBackend(target)
|
||||||
|
const debug = new HostDebuggerBackend(target, runtime)
|
||||||
|
return {
|
||||||
|
descriptor: this.descriptor,
|
||||||
|
context: this.context,
|
||||||
|
runtime: { state: 'supported', backend: runtime },
|
||||||
|
console: { state: 'supported', backend: console },
|
||||||
|
sources: { state: 'supported', backend: sources },
|
||||||
|
debugger: { state: 'supported', backend: debug },
|
||||||
|
nativeDomains: { state: 'supported', backend: target },
|
||||||
|
close: () => {
|
||||||
|
sources.close()
|
||||||
|
debug.close()
|
||||||
|
console.close()
|
||||||
|
runtime.close()
|
||||||
|
target.close()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
/** RuntimeBackend implementation over one native Node inspector session. */
|
||||||
|
|
||||||
|
import { inspectorId } from '../../../shared/identity.ts'
|
||||||
|
import type { RuntimeBackendObjectHandle } from '../../../shared/cdp/ids.ts'
|
||||||
|
import { isJsonValue } from '../../../shared/json.ts'
|
||||||
|
import { IDENTIFY_REALM_OBJECT_FUNCTION } from '../../../shared/cordis/object-registry.ts'
|
||||||
|
import { parseInspectorObjectReference, type InspectorObjectReference } from '../../../shared/cordis/object-reference.ts'
|
||||||
|
import type {
|
||||||
|
RuntimeCallArgument,
|
||||||
|
RuntimeCompletion,
|
||||||
|
RuntimeExceptionDetails,
|
||||||
|
RuntimeInternalPropertyDescriptor,
|
||||||
|
RuntimePrivatePropertyDescriptor,
|
||||||
|
RuntimeProperties,
|
||||||
|
RuntimePropertyDescriptor,
|
||||||
|
RuntimeRemoteObject,
|
||||||
|
RuntimeRemoteObjectDescriptor,
|
||||||
|
RuntimeStackTrace,
|
||||||
|
} from '../../../shared/cdp/index.ts'
|
||||||
|
import type { HostInspectorNotification, HostInspectorSession } from './bridge.ts'
|
||||||
|
import type { RuntimeBackend } from '../../../shared/cdp/realm.ts'
|
||||||
|
import { isNativeRecord, optionalNativeField, requireNativeRecord } from './values.ts'
|
||||||
|
import { hostScriptKey } from './scripts.ts'
|
||||||
|
|
||||||
|
/** Host Runtime adapter preserving native V8 semantics behind common values. */
|
||||||
|
export class HostRuntimeBackend implements RuntimeBackend {
|
||||||
|
private defaultContextId: number | undefined
|
||||||
|
private readonly unsubscribe: () => void
|
||||||
|
|
||||||
|
constructor(private readonly target: HostInspectorSession) {
|
||||||
|
this.unsubscribe = target.subscribe((message) => { this.observeContext(message) })
|
||||||
|
}
|
||||||
|
|
||||||
|
async enable(): Promise<void> {
|
||||||
|
await this.target.request('Runtime.enable', {})
|
||||||
|
}
|
||||||
|
|
||||||
|
async disable(): Promise<void> {
|
||||||
|
await this.target.request('Runtime.disable', {})
|
||||||
|
this.defaultContextId = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
async evaluate(request: Parameters<RuntimeBackend['evaluate']>[0]): ReturnType<RuntimeBackend['evaluate']> {
|
||||||
|
return this.completion(await this.target.request('Runtime.evaluate', {
|
||||||
|
expression: request.expression,
|
||||||
|
...optionalNativeField('objectGroup', request.objectGroup),
|
||||||
|
...optionalNativeField('includeCommandLineAPI', request.includeCommandLineAPI),
|
||||||
|
...optionalNativeField('silent', request.silent),
|
||||||
|
...optionalNativeField('returnByValue', request.returnByValue),
|
||||||
|
...optionalNativeField('generatePreview', request.generatePreview),
|
||||||
|
...optionalNativeField('userGesture', request.userGesture),
|
||||||
|
...optionalNativeField('awaitPromise', request.awaitPromise),
|
||||||
|
...optionalNativeField('disableBreaks', request.disableBreaks),
|
||||||
|
...optionalNativeField('replMode', request.replMode),
|
||||||
|
...optionalNativeField('allowUnsafeEvalBlockedByCSP', request.allowUnsafeEvalBlockedByCSP),
|
||||||
|
...optionalNativeField('throwOnSideEffect', request.throwOnSideEffect),
|
||||||
|
...optionalNativeField('serializationOptions', request.serializationOptions),
|
||||||
|
...optionalNativeField('timeout', request.timeoutMs),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProperties(request: Parameters<RuntimeBackend['getProperties']>[0]): ReturnType<RuntimeBackend['getProperties']> {
|
||||||
|
const response = await this.target.request('Runtime.getProperties', {
|
||||||
|
objectId: request.handle,
|
||||||
|
...optionalNativeField('ownProperties', request.ownProperties),
|
||||||
|
...optionalNativeField('accessorPropertiesOnly', request.accessorPropertiesOnly),
|
||||||
|
...optionalNativeField('generatePreview', request.generatePreview),
|
||||||
|
...optionalNativeField('nonIndexedPropertiesOnly', request.nonIndexedPropertiesOnly),
|
||||||
|
})
|
||||||
|
return this.properties(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
async callFunction(request: Parameters<RuntimeBackend['callFunction']>[0]): ReturnType<RuntimeBackend['callFunction']> {
|
||||||
|
const receiver = request.receiver
|
||||||
|
const contextId = receiver === undefined ? this.defaultContextId : undefined
|
||||||
|
if (receiver === undefined && contextId === undefined) {
|
||||||
|
throw new Error('Host Runtime default execution context is unavailable')
|
||||||
|
}
|
||||||
|
return this.completion(await this.target.request('Runtime.callFunctionOn', {
|
||||||
|
functionDeclaration: request.functionDeclaration,
|
||||||
|
...(receiver === undefined ? { executionContextId: contextId } : { objectId: receiver }),
|
||||||
|
...(request.arguments === undefined ? {} : { arguments: request.arguments.map(toNativeArgument) }),
|
||||||
|
...optionalNativeField('objectGroup', request.objectGroup),
|
||||||
|
...optionalNativeField('silent', request.silent),
|
||||||
|
...optionalNativeField('returnByValue', request.returnByValue),
|
||||||
|
...optionalNativeField('generatePreview', request.generatePreview),
|
||||||
|
...optionalNativeField('userGesture', request.userGesture),
|
||||||
|
...optionalNativeField('awaitPromise', request.awaitPromise),
|
||||||
|
...optionalNativeField('throwOnSideEffect', request.throwOnSideEffect),
|
||||||
|
...optionalNativeField('serializationOptions', request.serializationOptions),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async awaitPromise(request: Parameters<RuntimeBackend['awaitPromise']>[0]): ReturnType<RuntimeBackend['awaitPromise']> {
|
||||||
|
return this.completion(await this.target.request('Runtime.awaitPromise', {
|
||||||
|
promiseObjectId: request.promise,
|
||||||
|
...optionalNativeField('returnByValue', request.returnByValue),
|
||||||
|
...optionalNativeField('generatePreview', request.generatePreview),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async globalLexicalScopeNames(): Promise<readonly string[]> {
|
||||||
|
const response = await this.target.request('Runtime.globalLexicalScopeNames', {
|
||||||
|
...optionalNativeField('executionContextId', this.defaultContextId),
|
||||||
|
})
|
||||||
|
if (!Array.isArray(response.names) || !response.names.every(name => typeof name === 'string')) {
|
||||||
|
throw new Error('Host Runtime returned invalid lexical scope names')
|
||||||
|
}
|
||||||
|
return response.names
|
||||||
|
}
|
||||||
|
|
||||||
|
async releaseObject(handle: RuntimeBackendObjectHandle): Promise<void> {
|
||||||
|
await this.target.request('Runtime.releaseObject', { objectId: handle })
|
||||||
|
}
|
||||||
|
|
||||||
|
async releaseObjectGroup(group: string): Promise<void> {
|
||||||
|
await this.target.request('Runtime.releaseObjectGroup', { objectGroup: group })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Release the native-context observer owned by this backend. */
|
||||||
|
close(): void {
|
||||||
|
this.unsubscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a native Runtime completion returned through another Node domain.
|
||||||
|
* @param value - Native result and optional exception details.
|
||||||
|
* @returns The realm-neutral completion.
|
||||||
|
*/
|
||||||
|
async completion(value: Readonly<Record<string, unknown>>): Promise<RuntimeCompletion<RuntimeBackendObjectHandle>> {
|
||||||
|
return {
|
||||||
|
result: await this.remoteObject(value.result),
|
||||||
|
...(value.exceptionDetails === undefined
|
||||||
|
? {}
|
||||||
|
: { exceptionDetails: await this.exceptionDetails(value.exceptionDetails) }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async properties(value: Readonly<Record<string, unknown>>): Promise<RuntimeProperties<RuntimeBackendObjectHandle>> {
|
||||||
|
if (!Array.isArray(value.result)) throw new Error('Host Runtime returned invalid properties')
|
||||||
|
return {
|
||||||
|
properties: await Promise.all(value.result.map(item => this.property(item))),
|
||||||
|
...(value.internalProperties === undefined
|
||||||
|
? {}
|
||||||
|
: { internalProperties: await this.internalProperties(value.internalProperties) }),
|
||||||
|
...(value.privateProperties === undefined
|
||||||
|
? {}
|
||||||
|
: { privateProperties: await this.privateProperties(value.privateProperties) }),
|
||||||
|
...(value.exceptionDetails === undefined
|
||||||
|
? {}
|
||||||
|
: { exceptionDetails: await this.exceptionDetails(value.exceptionDetails) }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async property(value: unknown): Promise<RuntimePropertyDescriptor<RuntimeBackendObjectHandle>> {
|
||||||
|
const record = requireNativeRecord(value, 'Host Runtime property descriptor')
|
||||||
|
if (typeof record.name !== 'string'
|
||||||
|
|| typeof record.configurable !== 'boolean'
|
||||||
|
|| typeof record.enumerable !== 'boolean') {
|
||||||
|
throw new Error('Host Runtime returned invalid property descriptor')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...record,
|
||||||
|
name: record.name,
|
||||||
|
configurable: record.configurable,
|
||||||
|
enumerable: record.enumerable,
|
||||||
|
...(record.value === undefined ? {} : { value: await this.remoteObject(record.value) }),
|
||||||
|
...(record.get === undefined ? {} : { get: await this.remoteObject(record.get) }),
|
||||||
|
...(record.set === undefined ? {} : { set: await this.remoteObject(record.set) }),
|
||||||
|
...(record.symbol === undefined ? {} : { symbol: await this.remoteObject(record.symbol) }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async internalProperties(value: unknown): Promise<RuntimeInternalPropertyDescriptor<RuntimeBackendObjectHandle>[]> {
|
||||||
|
if (!Array.isArray(value)) throw new Error('Host Runtime returned invalid internal properties')
|
||||||
|
return Promise.all(value.map(async (item) => {
|
||||||
|
const record = requireNativeRecord(item, 'Host Runtime internal property')
|
||||||
|
if (typeof record.name !== 'string') throw new Error('Host Runtime returned invalid internal property')
|
||||||
|
return {
|
||||||
|
name: record.name,
|
||||||
|
...(record.value === undefined ? {} : { value: await this.remoteObject(record.value) }),
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
private async privateProperties(value: unknown): Promise<RuntimePrivatePropertyDescriptor<RuntimeBackendObjectHandle>[]> {
|
||||||
|
if (!Array.isArray(value)) throw new Error('Host Runtime returned invalid private properties')
|
||||||
|
return Promise.all(value.map(async (item) => {
|
||||||
|
const record = requireNativeRecord(item, 'Host Runtime private property')
|
||||||
|
if (typeof record.name !== 'string') throw new Error('Host Runtime returned invalid private property')
|
||||||
|
return {
|
||||||
|
name: record.name,
|
||||||
|
...(record.value === undefined ? {} : { value: await this.remoteObject(record.value) }),
|
||||||
|
...(record.get === undefined ? {} : { get: await this.remoteObject(record.get) }),
|
||||||
|
...(record.set === undefined ? {} : { set: await this.remoteObject(record.set) }),
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert native exception details to the common Runtime model.
|
||||||
|
* @param value - Native `Runtime.ExceptionDetails` fields.
|
||||||
|
* @returns Exception details with normalized object references.
|
||||||
|
*/
|
||||||
|
async exceptionDetails(value: unknown): Promise<RuntimeExceptionDetails<RuntimeBackendObjectHandle>> {
|
||||||
|
const record = requireNativeRecord(value, 'Host Runtime exception details')
|
||||||
|
if (typeof record.text !== 'string'
|
||||||
|
|| !Number.isSafeInteger(record.lineNumber)
|
||||||
|
|| !Number.isSafeInteger(record.columnNumber)) {
|
||||||
|
throw new Error('Host Runtime returned invalid exception details')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...record,
|
||||||
|
text: record.text,
|
||||||
|
lineNumber: record.lineNumber as number,
|
||||||
|
columnNumber: record.columnNumber as number,
|
||||||
|
...(record.stackTrace === undefined ? {} : { stackTrace: this.stackTrace(record.stackTrace) }),
|
||||||
|
...(record.exception === undefined ? {} : { exception: await this.remoteObject(record.exception) }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert one native V8 RemoteObject to the common Runtime model.
|
||||||
|
* @param value - Native `Runtime.RemoteObject` fields.
|
||||||
|
* @returns Descriptor, backend handle, and optional Cordis identity.
|
||||||
|
*/
|
||||||
|
async remoteObject(value: unknown): Promise<RuntimeRemoteObject<RuntimeBackendObjectHandle>> {
|
||||||
|
const record = requireNativeRecord(value, 'Host Runtime RemoteObject')
|
||||||
|
if (typeof record.type !== 'string') throw new Error('Host Runtime returned an invalid RemoteObject')
|
||||||
|
const descriptor = { ...record }
|
||||||
|
Reflect.deleteProperty(descriptor, 'objectId')
|
||||||
|
if (!isJsonValue(descriptor)) throw new Error('Host Runtime returned a non-JSON RemoteObject descriptor')
|
||||||
|
const objectId = typeof record.objectId === 'string' ? record.objectId : undefined
|
||||||
|
const semanticReference = objectId === undefined ? undefined : await this.identifyObject(objectId)
|
||||||
|
return {
|
||||||
|
descriptor: descriptor as unknown as RuntimeRemoteObjectDescriptor,
|
||||||
|
...(objectId === undefined ? {} : { object: { handle: backendHandle(objectId) } }),
|
||||||
|
...(semanticReference === undefined ? {} : { semanticReference }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a native stack trace while retaining native script identities.
|
||||||
|
* @param value - Native `Runtime.StackTrace` fields.
|
||||||
|
* @returns Realm-neutral stack frames.
|
||||||
|
*/
|
||||||
|
stackTrace(value: unknown): RuntimeStackTrace {
|
||||||
|
const record = requireNativeRecord(value, 'Host Runtime stack trace')
|
||||||
|
if (!Array.isArray(record.callFrames)) throw new Error('Host Runtime returned an invalid stack trace')
|
||||||
|
return {
|
||||||
|
...(typeof record.description === 'string' ? { description: record.description } : {}),
|
||||||
|
callFrames: record.callFrames.map((frame) => {
|
||||||
|
const fields = requireNativeRecord(frame, 'Host Runtime call frame')
|
||||||
|
if (typeof fields.functionName !== 'string'
|
||||||
|
|| typeof fields.url !== 'string'
|
||||||
|
|| !Number.isSafeInteger(fields.lineNumber)
|
||||||
|
|| !Number.isSafeInteger(fields.columnNumber)) {
|
||||||
|
throw new Error('Host Runtime returned an invalid call frame')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
functionName: fields.functionName,
|
||||||
|
...(typeof fields.scriptId === 'string'
|
||||||
|
? { scriptKey: hostScriptKey(fields.scriptId) }
|
||||||
|
: {}),
|
||||||
|
url: fields.url,
|
||||||
|
lineNumber: fields.lineNumber as number,
|
||||||
|
columnNumber: fields.columnNumber as number,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
...(record.parent === undefined ? {} : { parent: this.stackTrace(record.parent) }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private observeContext(message: HostInspectorNotification): void {
|
||||||
|
if (message.method === 'Runtime.executionContextCreated') {
|
||||||
|
const context = isNativeRecord(message.params?.context) ? message.params.context : undefined
|
||||||
|
const auxData = isNativeRecord(context?.auxData) ? context.auxData : undefined
|
||||||
|
if (context !== undefined && auxData?.isDefault === true && Number.isSafeInteger(context.id)) {
|
||||||
|
this.defaultContextId = context.id as number
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (message.method !== 'Runtime.executionContextDestroyed') return
|
||||||
|
if (message.params?.executionContextId === this.defaultContextId) this.defaultContextId = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
private async identifyObject(objectId: string): Promise<InspectorObjectReference | undefined> {
|
||||||
|
try {
|
||||||
|
const response = await this.target.request('Runtime.callFunctionOn', {
|
||||||
|
objectId,
|
||||||
|
functionDeclaration: IDENTIFY_REALM_OBJECT_FUNCTION,
|
||||||
|
returnByValue: true,
|
||||||
|
silent: true,
|
||||||
|
})
|
||||||
|
if (response.exceptionDetails !== undefined || !isNativeRecord(response.result)) return undefined
|
||||||
|
return response.result.value === undefined
|
||||||
|
? undefined
|
||||||
|
: parseInspectorObjectReference(response.result.value)
|
||||||
|
} catch {
|
||||||
|
// Semantic recognition is optional metadata; preserve the Runtime value on failure.
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNativeArgument(value: RuntimeCallArgument<RuntimeBackendObjectHandle>): Readonly<Record<string, unknown>> {
|
||||||
|
switch (value.kind) {
|
||||||
|
case 'value': return { value: value.value }
|
||||||
|
case 'unserializable': return { unserializableValue: value.value }
|
||||||
|
case 'object': return { objectId: value.handle }
|
||||||
|
case 'undefined': return {}
|
||||||
|
default: return assertNever(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function backendHandle(value: string): RuntimeBackendObjectHandle {
|
||||||
|
return inspectorId<'RuntimeBackendObjectHandle'>(value, 'Runtime backend object handle')
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNever(value: never): never {
|
||||||
|
throw new Error(`Unexpected Runtime call argument: ${JSON.stringify(value)}`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/** Host-native script identity conversion for normalized source and debugger values. */
|
||||||
|
|
||||||
|
import { inspectorId } from '../../../shared/identity.ts'
|
||||||
|
import type { RuntimeScriptKey } from '../../../shared/cdp/ids.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a Node inspector script id into the realm backend identity namespace.
|
||||||
|
* @param value - Native Node inspector script id.
|
||||||
|
* @returns The corresponding normalized script key.
|
||||||
|
*/
|
||||||
|
export function hostScriptKey(value: string): RuntimeScriptKey {
|
||||||
|
return inspectorId<'RuntimeScriptKey'>(value, 'scriptKey')
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/** SourceBackend implementation over native Node Debugger notifications. */
|
||||||
|
|
||||||
|
import type { RuntimeScriptKey } from '../../../shared/cdp/ids.ts'
|
||||||
|
import type { RuntimeScript } from '../../../shared/cdp/index.ts'
|
||||||
|
import type { HostInspectorNotification, HostInspectorSession } from './bridge.ts'
|
||||||
|
import type { SourceBackend } from '../../../shared/cdp/realm.ts'
|
||||||
|
import { hostScriptKey } from './scripts.ts'
|
||||||
|
|
||||||
|
interface HostScript {
|
||||||
|
readonly descriptor: RuntimeScript
|
||||||
|
readonly nativeId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maintains one connection-local catalog of scripts reported by Node's inspector. */
|
||||||
|
export class HostSourceBackend implements SourceBackend {
|
||||||
|
private readonly scripts = new Map<RuntimeScriptKey, HostScript>()
|
||||||
|
private readonly listeners = new Set<(script: RuntimeScript) => void>()
|
||||||
|
private readonly unsubscribe: () => void
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly target: HostInspectorSession,
|
||||||
|
) {
|
||||||
|
this.unsubscribe = target.subscribe((message) => { this.receive(message) })
|
||||||
|
}
|
||||||
|
|
||||||
|
listScripts(): Promise<readonly RuntimeScript[]> {
|
||||||
|
return Promise.resolve([...this.scripts.values()].map(script => script.descriptor))
|
||||||
|
}
|
||||||
|
|
||||||
|
async getScriptSource(scriptKey: RuntimeScriptKey): Promise<string> {
|
||||||
|
const script = this.scripts.get(scriptKey)
|
||||||
|
if (script === undefined) throw new Error('Host script is no longer available')
|
||||||
|
const result = await this.target.request('Debugger.getScriptSource', { scriptId: script.nativeId })
|
||||||
|
if (typeof result.scriptSource !== 'string') throw new Error('Host Debugger returned no script source')
|
||||||
|
return result.scriptSource
|
||||||
|
}
|
||||||
|
|
||||||
|
getSourceMap(_scriptKey: RuntimeScriptKey): Promise<string | undefined> {
|
||||||
|
return Promise.resolve(undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to scripts discovered after the initial catalog read.
|
||||||
|
* @param listener - Consumer of newly discovered scripts.
|
||||||
|
* @returns A disposer removing the consumer.
|
||||||
|
*/
|
||||||
|
subscribe(listener: (script: RuntimeScript) => void): () => void {
|
||||||
|
this.listeners.add(listener)
|
||||||
|
return () => { this.listeners.delete(listener) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Release the native notification subscription and cached catalog. */
|
||||||
|
close(): void {
|
||||||
|
this.unsubscribe()
|
||||||
|
this.scripts.clear()
|
||||||
|
this.listeners.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private receive(message: HostInspectorNotification): void {
|
||||||
|
if (message.method !== 'Debugger.scriptParsed') return
|
||||||
|
const params = message.params
|
||||||
|
if (params === undefined
|
||||||
|
|| typeof params.scriptId !== 'string'
|
||||||
|
|| typeof params.url !== 'string'
|
||||||
|
|| !isInteger(params.startLine)
|
||||||
|
|| !isInteger(params.startColumn)
|
||||||
|
|| !isInteger(params.endLine)
|
||||||
|
|| !isInteger(params.endColumn)) return
|
||||||
|
const scriptKey = hostScriptKey(params.scriptId)
|
||||||
|
const descriptor: RuntimeScript = {
|
||||||
|
scriptKey,
|
||||||
|
url: params.url,
|
||||||
|
hash: typeof params.hash === 'string' ? params.hash : '',
|
||||||
|
...(typeof params.buildId === 'string' ? { buildId: params.buildId } : {}),
|
||||||
|
startLine: params.startLine,
|
||||||
|
startColumn: params.startColumn,
|
||||||
|
endLine: params.endLine,
|
||||||
|
endColumn: params.endColumn,
|
||||||
|
...(typeof params.sourceMapURL === 'string' && params.sourceMapURL.length > 0
|
||||||
|
? { sourceMapUrl: params.sourceMapURL }
|
||||||
|
: {}),
|
||||||
|
...(isInteger(params.executionContextId) ? { executionContextId: params.executionContextId } : {}),
|
||||||
|
...(typeof params.isModule === 'boolean' ? { isModule: params.isModule } : {}),
|
||||||
|
...(isInteger(params.length) ? { length: params.length } : {}),
|
||||||
|
}
|
||||||
|
this.scripts.set(scriptKey, { descriptor, nativeId: params.scriptId })
|
||||||
|
for (const listener of [...this.listeners]) {
|
||||||
|
try {
|
||||||
|
listener(descriptor)
|
||||||
|
} catch {
|
||||||
|
// One source consumer cannot prevent delivery to sibling consumers.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInteger(value: unknown): value is number {
|
||||||
|
return Number.isSafeInteger(value) && (value as number) >= 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/** Small validators for values returned by Node's native Inspector protocol. */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test whether a native protocol value is a non-array object record.
|
||||||
|
* @param value - Native protocol value.
|
||||||
|
* @returns Whether the value can be read as named fields.
|
||||||
|
*/
|
||||||
|
export function isNativeRecord(value: unknown): value is Readonly<Record<string, unknown>> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require a native protocol object record.
|
||||||
|
* @param value - Native protocol value.
|
||||||
|
* @param label - Subject named in the validation error.
|
||||||
|
* @returns The validated object record.
|
||||||
|
*/
|
||||||
|
export function requireNativeRecord(value: unknown, label: string): Readonly<Record<string, unknown>> {
|
||||||
|
if (!isNativeRecord(value)) throw new Error(`${label} must be an object`)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Include an optional field only when the native request supplied a value.
|
||||||
|
* @param key - Native protocol field name.
|
||||||
|
* @param value - Optional field value.
|
||||||
|
* @returns An empty record or the requested field.
|
||||||
|
*/
|
||||||
|
export function optionalNativeField<Key extends string, Value>(
|
||||||
|
key: Key,
|
||||||
|
value: Value | undefined,
|
||||||
|
): Partial<Record<Key, Value>> {
|
||||||
|
return value === undefined ? {} : { [key]: value } as Partial<Record<Key, Value>>
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/** Inspector Worker assembly over one Host source port and one loopback endpoint. */
|
||||||
|
|
||||||
|
import type { MessagePort } from 'node:worker_threads'
|
||||||
|
import type { InspectorWorkerBoot } from '../shared/bridge/messages/control.ts'
|
||||||
|
import type { WorkerToSourceFrame } from '../shared/bridge/messages/observation.ts'
|
||||||
|
import { createCordisRuntimeTreeReader } from '../shared/cordis/reader.ts'
|
||||||
|
import { NetworkDomain } from './cdp/domains/network/session.ts'
|
||||||
|
import { NetworkStore } from './inspection/network-store.ts'
|
||||||
|
import { CordisDomBackend } from './cdp/domains/dom/index.ts'
|
||||||
|
import { ClientRuntimeRouter } from './bridge/runtime-rpc.ts'
|
||||||
|
import { ClientSourceRouter } from './bridge/source-rpc.ts'
|
||||||
|
import { CordisTreeStore } from './inspection/cordis-store.ts'
|
||||||
|
import { InspectorEndpoint, type InspectorEndpointInfo } from './bridge/endpoint.ts'
|
||||||
|
import { InspectorQueryRouter } from './inspection/query-router.ts'
|
||||||
|
import { InspectorRealmRegistry } from './inspection/realm-store.ts'
|
||||||
|
import { HostInspectorRealm } from './realms/host/index.ts'
|
||||||
|
import { InspectorSourceRegistry, type SourceConnection } from './bridge/hub.ts'
|
||||||
|
|
||||||
|
/** Live Worker runtime. */
|
||||||
|
export interface InspectorWorkerRuntime {
|
||||||
|
readonly endpoint: InspectorEndpointInfo
|
||||||
|
close(): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assemble and start the Worker-owned source registry, Runtime router, Network domain, and endpoints.
|
||||||
|
* @param boot - Validated Worker configuration and transferred Host source port.
|
||||||
|
* @returns The listening endpoint and quiescent shutdown owner.
|
||||||
|
*/
|
||||||
|
export async function startInspectorWorker(boot: InspectorWorkerBoot<MessagePort>): Promise<InspectorWorkerRuntime> {
|
||||||
|
const networkStore = new NetworkStore({
|
||||||
|
maxRetainedRequests: boot.config.maxRetainedRequests,
|
||||||
|
maxJournalBytes: boot.config.maxJournalBytes,
|
||||||
|
})
|
||||||
|
const network = new NetworkDomain(networkStore)
|
||||||
|
const cordisTrees = new CordisTreeStore({
|
||||||
|
maxNodes: boot.config.maxCordisNodes,
|
||||||
|
maxDisconnectedTrees: boot.config.maxDisconnectedCordisTrees,
|
||||||
|
})
|
||||||
|
const sources = new InspectorSourceRegistry(
|
||||||
|
[networkStore, cordisTrees],
|
||||||
|
boot.config.maxSourceFrameBytes,
|
||||||
|
boot.config.maxSourceRecordsPerFrame,
|
||||||
|
)
|
||||||
|
const clientRuntime = new ClientRuntimeRouter(sources, boot.config.clientRuntimeTimeoutMs)
|
||||||
|
const clientSources = new ClientSourceRouter(
|
||||||
|
sources,
|
||||||
|
boot.config.clientRuntimeTimeoutMs,
|
||||||
|
boot.config.maxClientSourceBytes,
|
||||||
|
boot.config.maxSourceFrameBytes,
|
||||||
|
)
|
||||||
|
const realms = new InspectorRealmRegistry(new HostInspectorRealm('Host'), clientRuntime, clientSources)
|
||||||
|
const cordisDom = new CordisDomBackend(cordisTrees)
|
||||||
|
const cordisReader = createCordisRuntimeTreeReader(() => cordisTrees.readTree())
|
||||||
|
const queries = new InspectorQueryRouter(cordisReader, boot.config.maxSourceFrameBytes)
|
||||||
|
const unsubscribeQueries = sources.subscribeEvents((event) => {
|
||||||
|
if (event.type === 'closed') queries.disconnect(event.source)
|
||||||
|
})
|
||||||
|
const hostQueries = queries.open({
|
||||||
|
send: (frame) => { boot.hostSourcePort.postMessage(frame) },
|
||||||
|
close: () => { boot.hostSourcePort.close() },
|
||||||
|
})
|
||||||
|
const hostConnection: SourceConnection = {
|
||||||
|
kind: 'host',
|
||||||
|
send: (frame: WorkerToSourceFrame) => {
|
||||||
|
boot.hostSourcePort.postMessage(frame)
|
||||||
|
if (frame.t === 'source/accepted') hostQueries.accept(frame.sourceId, frame.generation)
|
||||||
|
},
|
||||||
|
close: () => { boot.hostSourcePort.close() },
|
||||||
|
}
|
||||||
|
boot.hostSourcePort.on('message', (value: unknown) => {
|
||||||
|
if (!hostQueries.receive(value)) sources.receive(hostConnection, value)
|
||||||
|
})
|
||||||
|
boot.hostSourcePort.on('close', () => {
|
||||||
|
hostQueries.close()
|
||||||
|
sources.disconnect(hostConnection, 'Host source disconnected')
|
||||||
|
})
|
||||||
|
boot.hostSourcePort.start()
|
||||||
|
|
||||||
|
const endpointOwner = new InspectorEndpoint(
|
||||||
|
boot.config,
|
||||||
|
sources,
|
||||||
|
network,
|
||||||
|
realms,
|
||||||
|
cordisDom,
|
||||||
|
cordisReader,
|
||||||
|
queries,
|
||||||
|
)
|
||||||
|
const endpoint = await endpointOwner.start()
|
||||||
|
let closed: Promise<void> | undefined
|
||||||
|
return {
|
||||||
|
endpoint,
|
||||||
|
close(): Promise<void> {
|
||||||
|
closed ??= (async () => {
|
||||||
|
await endpointOwner.close()
|
||||||
|
network.close()
|
||||||
|
networkStore.dispose()
|
||||||
|
cordisDom.close()
|
||||||
|
realms.close()
|
||||||
|
clientRuntime.close()
|
||||||
|
clientSources.close()
|
||||||
|
hostQueries.close()
|
||||||
|
sources.close()
|
||||||
|
unsubscribeQueries()
|
||||||
|
queries.close()
|
||||||
|
boot.hostSourcePort.close()
|
||||||
|
})()
|
||||||
|
return closed
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { existsSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { execa } from 'execa'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const packageDirectory = fileURLToPath(new URL('..', import.meta.url))
|
||||||
|
const built = [
|
||||||
|
'lib/index.js',
|
||||||
|
'lib/worker.js',
|
||||||
|
'node_modules/@deepseek-ai/schemastery/lib/index.mjs',
|
||||||
|
].every(file => existsSync(join(packageDirectory, file)))
|
||||||
|
|
||||||
|
describe.skipIf(!built)('experimental Inspector built artifact', () => {
|
||||||
|
it('starts its sibling Worker and evaluates the Host through plain Node', async () => {
|
||||||
|
const script = `
|
||||||
|
const { startInspector } = await import('@deepseek-ai/dsh-experimental-inspector')
|
||||||
|
const { default: WebSocket } = await import('ws')
|
||||||
|
globalThis.__builtInspectorProbe = 42
|
||||||
|
const inspector = await startInspector({ port: 0, captureFetch: false })
|
||||||
|
const socket = new WebSocket(inspector.endpoint.webSocketDebuggerUrl)
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
socket.once('open', resolve)
|
||||||
|
socket.once('error', reject)
|
||||||
|
})
|
||||||
|
const response = new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error('CDP response timeout')), 5000)
|
||||||
|
socket.on('message', data => {
|
||||||
|
const message = JSON.parse(Buffer.from(data).toString('utf8'))
|
||||||
|
if (message.id !== 1) return
|
||||||
|
clearTimeout(timer)
|
||||||
|
resolve(message)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
id: 1,
|
||||||
|
method: 'Runtime.evaluate',
|
||||||
|
params: { expression: 'globalThis.__builtInspectorProbe', returnByValue: true },
|
||||||
|
}))
|
||||||
|
const message = await response
|
||||||
|
socket.close()
|
||||||
|
await inspector.close()
|
||||||
|
console.log(JSON.stringify(message.result.result))
|
||||||
|
`
|
||||||
|
const result = await execa(process.execPath, ['--input-type=module', '-e', script], {
|
||||||
|
cwd: packageDirectory,
|
||||||
|
stdin: 'ignore',
|
||||||
|
timeout: 20_000,
|
||||||
|
killSignal: 'SIGKILL',
|
||||||
|
reject: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.exitCode, `stderr:\n${result.stderr}`).toBe(0)
|
||||||
|
expect(JSON.parse(result.stdout.trim()) as unknown).toEqual({ type: 'number', value: 42, description: '42' })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import { existsSync } from 'node:fs'
|
||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import { createServer, type Server } from 'node:http'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { chromium, type Browser, type Page } from 'playwright'
|
||||||
|
import WebSocket, { type RawData } from 'ws'
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { startInspector, type InspectorHandle } from '../src/host/bridge/controller.ts'
|
||||||
|
|
||||||
|
const packageDirectory = fileURLToPath(new URL('..', import.meta.url))
|
||||||
|
const clientBundlePath = join(packageDirectory, 'lib/client.js')
|
||||||
|
const clientSourceMapPath = join(packageDirectory, 'lib/client.js.map')
|
||||||
|
const built = existsSync(clientBundlePath) && existsSync(clientSourceMapPath)
|
||||||
|
|
||||||
|
interface CdpMessage {
|
||||||
|
readonly id?: number
|
||||||
|
readonly method?: string
|
||||||
|
readonly params?: Record<string, unknown>
|
||||||
|
readonly result?: Record<string, unknown>
|
||||||
|
readonly error?: { message: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
class BrowserTestCdpClient {
|
||||||
|
private nextId = 0
|
||||||
|
private readonly pending = new Map<number, (message: CdpMessage) => void>()
|
||||||
|
private readonly events: CdpMessage[] = []
|
||||||
|
private readonly waiters = new Set<() => void>()
|
||||||
|
|
||||||
|
private constructor(private readonly socket: WebSocket) {
|
||||||
|
socket.on('message', (data) => {
|
||||||
|
const message = JSON.parse(rawText(data)) as CdpMessage
|
||||||
|
if (message.id !== undefined) {
|
||||||
|
this.pending.get(message.id)?.(message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.events.push(message)
|
||||||
|
for (const waiter of [...this.waiters]) waiter()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
static async connect(url: string): Promise<BrowserTestCdpClient> {
|
||||||
|
const socket = new WebSocket(url)
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
socket.once('open', () => { resolve() })
|
||||||
|
socket.once('error', reject)
|
||||||
|
})
|
||||||
|
return new BrowserTestCdpClient(socket)
|
||||||
|
}
|
||||||
|
|
||||||
|
call(method: string, params: Record<string, unknown> = {}): Promise<CdpMessage> {
|
||||||
|
const id = ++this.nextId
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.pending.delete(id)
|
||||||
|
reject(new Error(`CDP call timed out: ${method}`))
|
||||||
|
}, 5_000)
|
||||||
|
this.pending.set(id, (message) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
this.pending.delete(id)
|
||||||
|
resolve(message)
|
||||||
|
})
|
||||||
|
this.socket.send(JSON.stringify({ id, method, params }))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
waitForEvent(method: string, predicate: (message: CdpMessage) => boolean): Promise<CdpMessage> {
|
||||||
|
const existing = this.events.find(event => event.method === method && predicate(event))
|
||||||
|
if (existing !== undefined) return Promise.resolve(existing)
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.waiters.delete(check)
|
||||||
|
reject(new Error(`CDP event timed out: ${method}`))
|
||||||
|
}, 5_000)
|
||||||
|
const check = (): void => {
|
||||||
|
const event = this.events.find(candidate => candidate.method === method && predicate(candidate))
|
||||||
|
if (event === undefined) return
|
||||||
|
clearTimeout(timer)
|
||||||
|
this.waiters.delete(check)
|
||||||
|
resolve(event)
|
||||||
|
}
|
||||||
|
this.waiters.add(check)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
if (this.socket.readyState === WebSocket.CLOSED) return
|
||||||
|
const closed = new Promise<void>((resolve) => { this.socket.once('close', () => { resolve() }) })
|
||||||
|
this.socket.close()
|
||||||
|
await closed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.skipIf(!built)('Inspector built Client in Chromium', () => {
|
||||||
|
let inspector: InspectorHandle | undefined
|
||||||
|
let server: Server | undefined
|
||||||
|
let browser: Browser | undefined
|
||||||
|
let page: Page | undefined
|
||||||
|
let cdp: BrowserTestCdpClient | undefined
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await page?.evaluate(() => {
|
||||||
|
const state = Reflect.get(globalThis, '__INSPECTOR_BROWSER_TEST__') as { dispose?: () => void } | undefined
|
||||||
|
state?.dispose?.()
|
||||||
|
}).catch(() => {})
|
||||||
|
await cdp?.close()
|
||||||
|
await browser?.close()
|
||||||
|
await inspector?.close()
|
||||||
|
if (server !== undefined) await new Promise<void>((resolve) => { server!.close(() => { resolve() }) })
|
||||||
|
page = undefined
|
||||||
|
cdp = undefined
|
||||||
|
browser = undefined
|
||||||
|
inspector = undefined
|
||||||
|
server = undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
it('forwards Console values and exposes the built bundle as read-only source', async () => {
|
||||||
|
inspector = await startInspector({ port: 0, captureFetch: false, maxClientSourceBytes: 1_000_000 })
|
||||||
|
const bundle = await readFile(clientBundlePath)
|
||||||
|
const sourceMap = await readFile(clientSourceMapPath)
|
||||||
|
server = createServer((request, response) => {
|
||||||
|
const url = new URL(request.url ?? '/', 'http://127.0.0.1')
|
||||||
|
if (url.pathname === '/client.js') {
|
||||||
|
response.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' })
|
||||||
|
response.end(bundle)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (url.pathname === '/client.js.map') {
|
||||||
|
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
|
||||||
|
response.end(sourceMap)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
||||||
|
response.end(browserFixture(inspector!.endpoint.client))
|
||||||
|
})
|
||||||
|
await new Promise<void>((resolve) => { server!.listen(0, '127.0.0.1', () => { resolve() }) })
|
||||||
|
const port = (server.address() as import('node:net').AddressInfo).port
|
||||||
|
|
||||||
|
browser = await chromium.launch()
|
||||||
|
page = await browser.newPage()
|
||||||
|
await page.goto(`http://127.0.0.1:${String(port)}/`)
|
||||||
|
await page.waitForFunction(() => Reflect.get(globalThis, '__INSPECTOR_BROWSER_TEST__') !== undefined)
|
||||||
|
|
||||||
|
cdp = await BrowserTestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
|
||||||
|
await cdp.call('Runtime.enable')
|
||||||
|
const contextEvent = await cdp.waitForEvent('Runtime.executionContextCreated', (event) => {
|
||||||
|
const context = event.params?.context as Record<string, unknown> | undefined
|
||||||
|
return String(context?.name).startsWith('Client —')
|
||||||
|
})
|
||||||
|
const contextId = (contextEvent.params?.context as Record<string, unknown>).id
|
||||||
|
expect(contextId).toBeTypeOf('number')
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const value = { browser: true, nested: { ready: true } }
|
||||||
|
Reflect.set(globalThis, '__inspectorBrowserValue', value)
|
||||||
|
console.log(value, 'browser-client-console')
|
||||||
|
setTimeout(() => { throw new Error('browser-client-exception') }, 0)
|
||||||
|
})
|
||||||
|
const consoleEvent = await cdp.waitForEvent('Runtime.consoleAPICalled', event =>
|
||||||
|
event.params?.executionContextId === contextId && hasArgument(event, 'browser-client-console'))
|
||||||
|
const args = consoleEvent.params?.args
|
||||||
|
if (!Array.isArray(args)) throw new Error('Client Console event has no arguments')
|
||||||
|
expect((consoleEvent.params?.stackTrace as { callFrames?: unknown[] } | undefined)?.callFrames?.length)
|
||||||
|
.toBeGreaterThan(0)
|
||||||
|
const objectId = asRecord(args[0]).objectId
|
||||||
|
expect(String(objectId)).toMatch(/^runtime:/u)
|
||||||
|
const properties = await cdp.call('Runtime.getProperties', { objectId, ownProperties: true })
|
||||||
|
expect(propertyValue(properties, 'browser')).toBe(true)
|
||||||
|
const exception = await cdp.waitForEvent('Runtime.exceptionThrown', (event) => {
|
||||||
|
const details = event.params?.exceptionDetails as Record<string, unknown> | undefined
|
||||||
|
return details !== undefined
|
||||||
|
&& details.executionContextId === contextId
|
||||||
|
&& String((details.exception as Record<string, unknown> | undefined)?.description).includes('browser-client-exception')
|
||||||
|
})
|
||||||
|
const exceptionDetails = exception.params?.exceptionDetails as Record<string, unknown>
|
||||||
|
expect((exceptionDetails.stackTrace as { callFrames?: unknown[] } | undefined)?.callFrames?.length)
|
||||||
|
.toBeGreaterThan(0)
|
||||||
|
|
||||||
|
const enabled = await cdp.call('Debugger.enable')
|
||||||
|
expect(enabled.error).toBeUndefined()
|
||||||
|
expect(enabled.result?.debuggerId).toBeTypeOf('string')
|
||||||
|
const script = await cdp.waitForEvent('Debugger.scriptParsed', event =>
|
||||||
|
String(event.params?.url).includes('/client.js?rev=browser-test'))
|
||||||
|
expect(script.params).toMatchObject({ executionContextId: contextId, buildId: '' })
|
||||||
|
const scriptId = script.params?.scriptId
|
||||||
|
const content = await cdp.call('Debugger.getScriptSource', { scriptId })
|
||||||
|
expect(String(content.result?.scriptSource)).toContain('ClientInspectorSource')
|
||||||
|
expect((await cdp.call('Debugger.setBreakpointByUrl', {
|
||||||
|
url: script.params?.url,
|
||||||
|
lineNumber: 0,
|
||||||
|
})).error?.message).toContain('Client native debugging is unavailable')
|
||||||
|
}, 20_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
function browserFixture(bootstrap: InspectorHandle['endpoint']['client']): string {
|
||||||
|
const boot = {
|
||||||
|
rev: 'browser-test',
|
||||||
|
entries: [{
|
||||||
|
id: '@deepseek-ai/dsh-experimental-inspector',
|
||||||
|
url: '/client.js?rev=browser-test',
|
||||||
|
rev: 'browser-test',
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
return `<!doctype html>
|
||||||
|
<title>Inspector Browser Client</title>
|
||||||
|
<script>
|
||||||
|
globalThis.__DSH_INSPECTOR__ = ${JSON.stringify(bootstrap)};
|
||||||
|
globalThis.__DSH_BOOT__ = ${JSON.stringify(boot)};
|
||||||
|
globalThis.__ModuleLoader__ = { load(registration) { globalThis.__INSPECTOR_REGISTRATION__ = registration; } };
|
||||||
|
</script>
|
||||||
|
<script src="/client.js?rev=browser-test"></script>
|
||||||
|
<script>
|
||||||
|
const registration = globalThis.__INSPECTOR_REGISTRATION__;
|
||||||
|
const disposers = [];
|
||||||
|
const root = {
|
||||||
|
__inspectorContext: true,
|
||||||
|
registry: new Map(),
|
||||||
|
events: { _hooks: {} },
|
||||||
|
effect(callback) { const dispose = callback(); disposers.push(dispose); return dispose; },
|
||||||
|
on() { return () => {}; },
|
||||||
|
provide(name, value) { this[name] = value; return () => { delete this[name]; }; },
|
||||||
|
};
|
||||||
|
root.root = root;
|
||||||
|
const cordis = { Context: { is(value) { return value?.__inspectorContext === true; } } };
|
||||||
|
const plugin = registration.factory(specifier => {
|
||||||
|
if (specifier === '@deepseek-ai/cordis') return cordis;
|
||||||
|
throw new Error('Unexpected Client bundle dependency ' + specifier);
|
||||||
|
});
|
||||||
|
plugin.apply(root);
|
||||||
|
globalThis.__INSPECTOR_BROWSER_TEST__ = {
|
||||||
|
dispose() { for (const dispose of disposers.reverse()) dispose(); },
|
||||||
|
};
|
||||||
|
</script>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasArgument(event: CdpMessage, value: unknown): boolean {
|
||||||
|
const args = event.params?.args
|
||||||
|
return Array.isArray(args) && args.some(argument => asRecord(argument).value === value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function propertyValue(response: CdpMessage, name: string): unknown {
|
||||||
|
const result = response.result?.result
|
||||||
|
if (!Array.isArray(result)) throw new Error('Runtime.getProperties returned no property list')
|
||||||
|
const property = result.map(asRecord).find(candidate => candidate.name === name)
|
||||||
|
return asRecord(property?.value).value
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Readonly<Record<string, unknown>> {
|
||||||
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('expected a record')
|
||||||
|
return value as Readonly<Record<string, unknown>>
|
||||||
|
}
|
||||||
|
|
||||||
|
function rawText(data: RawData): string {
|
||||||
|
if (Array.isArray(data)) return Buffer.concat(data).toString('utf8')
|
||||||
|
if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8')
|
||||||
|
return Buffer.from(data).toString('utf8')
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { inspectorId } from '../src/shared/bridge/ids.ts'
|
||||||
|
import { ClientScriptIdentity } from '../src/worker/realms/client/scripts.ts'
|
||||||
|
import { clientConsoleEvent } from '../src/worker/realms/client/values.ts'
|
||||||
|
|
||||||
|
describe('Worker Client stack projection', () => {
|
||||||
|
it('uses one script key in Client Console and Sources projections', () => {
|
||||||
|
const localKey = inspectorId<'RuntimeScriptKey'>('client-bundle', 'scriptKey')
|
||||||
|
const scripts = new ClientScriptIdentity(-7)
|
||||||
|
const projected = clientConsoleEvent({
|
||||||
|
type: 'console-api',
|
||||||
|
event: {
|
||||||
|
type: 'log',
|
||||||
|
arguments: [],
|
||||||
|
timestamp: 1,
|
||||||
|
stackTrace: {
|
||||||
|
callFrames: [{
|
||||||
|
functionName: 'apply',
|
||||||
|
scriptKey: localKey,
|
||||||
|
url: 'http://client.test/client.js',
|
||||||
|
lineNumber: 1,
|
||||||
|
columnNumber: 2,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, scriptKey => scripts.toRuntime(scriptKey))
|
||||||
|
if (projected.type !== 'console-api') throw new Error('unexpected exception event')
|
||||||
|
expect(projected.event.stackTrace?.callFrames[0]?.scriptKey).toBe(scripts.toRuntime(localKey))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import WebSocket, { type RawData } from 'ws'
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { isPlainObject } from '../src/shared/json.ts'
|
||||||
|
|
||||||
|
interface CdpMessage {
|
||||||
|
readonly id?: number
|
||||||
|
readonly method?: string
|
||||||
|
readonly params?: Record<string, unknown>
|
||||||
|
readonly result?: Record<string, unknown>
|
||||||
|
readonly error?: { message: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
class CdpClient {
|
||||||
|
private nextId = 0
|
||||||
|
private readonly pending = new Map<number, (message: CdpMessage) => void>()
|
||||||
|
private readonly events: CdpMessage[] = []
|
||||||
|
private readonly eventWaiters = new Set<() => void>()
|
||||||
|
|
||||||
|
private constructor(private readonly socket: WebSocket) {
|
||||||
|
socket.on('message', (data) => {
|
||||||
|
const message = JSON.parse(rawText(data)) as CdpMessage
|
||||||
|
if (message.id !== undefined) this.pending.get(message.id)?.(message)
|
||||||
|
else {
|
||||||
|
this.events.push(message)
|
||||||
|
for (const wake of [...this.eventWaiters]) wake()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
static async connect(url: string): Promise<CdpClient> {
|
||||||
|
const socket = new WebSocket(url)
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
socket.once('open', () => { resolve() })
|
||||||
|
socket.once('error', reject)
|
||||||
|
})
|
||||||
|
return new CdpClient(socket)
|
||||||
|
}
|
||||||
|
|
||||||
|
call(method: string, params: Record<string, unknown> = {}): Promise<CdpMessage> {
|
||||||
|
const id = ++this.nextId
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => { reject(new Error(`CDP call timed out: ${method}`)) }, 5_000)
|
||||||
|
this.pending.set(id, (message) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
this.pending.delete(id)
|
||||||
|
resolve(message)
|
||||||
|
})
|
||||||
|
this.socket.send(JSON.stringify({ id, method, params }))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
waitForEvent(method: string, predicate: (event: CdpMessage) => boolean = () => true): Promise<CdpMessage> {
|
||||||
|
const found = this.events.find(event => event.method === method && predicate(event))
|
||||||
|
if (found !== undefined) return Promise.resolve(found)
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.eventWaiters.delete(check)
|
||||||
|
reject(new Error(`CDP event timed out: ${method}`))
|
||||||
|
}, 5_000)
|
||||||
|
const check = (): void => {
|
||||||
|
const event = this.events.find(candidate => candidate.method === method && predicate(candidate))
|
||||||
|
if (event === undefined) return
|
||||||
|
clearTimeout(timer)
|
||||||
|
this.eventWaiters.delete(check)
|
||||||
|
resolve(event)
|
||||||
|
}
|
||||||
|
this.eventWaiters.add(check)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
if (this.socket.readyState === WebSocket.CLOSED) return
|
||||||
|
const closed = new Promise<void>((resolve) => { this.socket.once('close', () => { resolve() }) })
|
||||||
|
this.socket.close()
|
||||||
|
await closed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rawText(data: RawData): string {
|
||||||
|
if (Array.isArray(data)) return Buffer.concat(data).toString('utf8')
|
||||||
|
if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8')
|
||||||
|
return Buffer.from(data).toString('utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Host debugger through the Inspector Worker', () => {
|
||||||
|
let child: ChildProcessWithoutNullStreams | undefined
|
||||||
|
let cdp: CdpClient | undefined
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await cdp?.close()
|
||||||
|
cdp = undefined
|
||||||
|
if (child !== undefined && child.exitCode === null) child.kill('SIGKILL')
|
||||||
|
child = undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
it('evaluates a paused Host frame and resumes while the main thread is stopped', async () => {
|
||||||
|
const fixture = fileURLToPath(new URL('./fixtures/debug-host.ts', import.meta.url))
|
||||||
|
const tsx = import.meta.resolve('tsx/esm')
|
||||||
|
child = spawn(process.execPath, ['--import', tsx, fixture], {
|
||||||
|
env: { ...process.env, TSX_TSCONFIG_PATH: fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) },
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
})
|
||||||
|
const firstLine = await readLine(child)
|
||||||
|
const endpoint = JSON.parse(firstLine) as { webSocketDebuggerUrl: string }
|
||||||
|
cdp = await CdpClient.connect(endpoint.webSocketDebuggerUrl)
|
||||||
|
expect((await cdp.call('Runtime.enable')).error).toBeUndefined()
|
||||||
|
expect((await cdp.call('Debugger.enable')).error).toBeUndefined()
|
||||||
|
const parsed = await cdp.waitForEvent('Debugger.scriptParsed', event =>
|
||||||
|
String(event.params?.url).endsWith('/debug-host.ts'))
|
||||||
|
const scriptId = parsed.params?.scriptId
|
||||||
|
expect(typeof scriptId).toBe('string')
|
||||||
|
const source = await cdp.call('Debugger.getScriptSource', { scriptId })
|
||||||
|
expect(source.result?.scriptSource).toContain('breakpointProbe')
|
||||||
|
await cdp.call('Runtime.evaluate', { expression: 'console.log("host-console-probe")' })
|
||||||
|
const consoleEvent = await cdp.waitForEvent('Runtime.consoleAPICalled', (event) => {
|
||||||
|
const args = event.params?.args
|
||||||
|
return Array.isArray(args) && args.some(arg => isPlainObject(arg) && arg.value === 'host-console-probe')
|
||||||
|
})
|
||||||
|
expect(consoleEvent.params?.type).toBe('log')
|
||||||
|
const evaluated = await cdp.call('Runtime.evaluate', {
|
||||||
|
expression: 'globalThis.__inspectorBreakpointProbe',
|
||||||
|
})
|
||||||
|
const objectId = (evaluated.result?.result as Record<string, unknown> | undefined)?.objectId
|
||||||
|
expect(typeof objectId).toBe('string')
|
||||||
|
expect((await cdp.call('Debugger.setBreakpointOnFunctionCall', { objectId })).error).toBeUndefined()
|
||||||
|
|
||||||
|
child.stdin.write('run\n')
|
||||||
|
const paused = await cdp.waitForEvent('Debugger.paused')
|
||||||
|
const callFrames = paused.params?.callFrames as Array<Record<string, unknown>>
|
||||||
|
const callFrameId = callFrames[0]?.callFrameId
|
||||||
|
expect(typeof callFrameId).toBe('string')
|
||||||
|
const scopeChain = callFrames[0]?.scopeChain as Array<Record<string, unknown>>
|
||||||
|
const scopeObjectId = (scopeChain[0]?.object as Record<string, unknown> | undefined)?.objectId
|
||||||
|
expect(String(scopeObjectId)).toMatch(/^runtime:/u)
|
||||||
|
expect((await cdp.call('Runtime.getProperties', { objectId: scopeObjectId })).error).toBeUndefined()
|
||||||
|
|
||||||
|
// This Worker-local request must complete while the Host main thread is paused.
|
||||||
|
expect((await cdp.call('DSHInspector.getSources')).result?.sources).toBeDefined()
|
||||||
|
const local = await cdp.call('Debugger.evaluateOnCallFrame', {
|
||||||
|
callFrameId,
|
||||||
|
expression: 'value',
|
||||||
|
returnByValue: true,
|
||||||
|
})
|
||||||
|
expect(local.result?.result).toMatchObject({ type: 'number', value: 41 })
|
||||||
|
const object = await cdp.call('Debugger.evaluateOnCallFrame', {
|
||||||
|
callFrameId,
|
||||||
|
expression: '({ pausedValue: value })',
|
||||||
|
objectGroup: 'backtrace',
|
||||||
|
})
|
||||||
|
const pausedObjectId = (object.result?.result as Record<string, unknown> | undefined)?.objectId
|
||||||
|
expect(String(pausedObjectId)).toMatch(/^runtime:/u)
|
||||||
|
expect((await cdp.call('Runtime.getProperties', { objectId: pausedObjectId })).error).toBeUndefined()
|
||||||
|
expect((await cdp.call('Debugger.resume')).error).toBeUndefined()
|
||||||
|
const completed = await cdp.call('Runtime.evaluate', {
|
||||||
|
expression: 'globalThis.__inspectorBreakpointResult',
|
||||||
|
returnByValue: true,
|
||||||
|
})
|
||||||
|
expect(completed.result?.result).toMatchObject({ type: 'number', value: 42 })
|
||||||
|
|
||||||
|
const exited = new Promise<number | null>((resolve) => { child!.once('exit', resolve) })
|
||||||
|
child.stdin.write('stop\n')
|
||||||
|
expect(await exited).toBe(0)
|
||||||
|
child = undefined
|
||||||
|
cdp = undefined
|
||||||
|
}, 20_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
function readLine(child: ChildProcessWithoutNullStreams): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let stdout = ''
|
||||||
|
let stderr = ''
|
||||||
|
const onData = (chunk: Buffer): void => {
|
||||||
|
stdout += chunk.toString('utf8')
|
||||||
|
const newline = stdout.indexOf('\n')
|
||||||
|
if (newline === -1) return
|
||||||
|
cleanup()
|
||||||
|
resolve(stdout.slice(0, newline))
|
||||||
|
}
|
||||||
|
const onError = (error: Error): void => { cleanup(); reject(error) }
|
||||||
|
const onExit = (): void => {
|
||||||
|
cleanup()
|
||||||
|
reject(new Error(`debug Host exited before output; stderr:\n${stderr}`))
|
||||||
|
}
|
||||||
|
const onStderr = (chunk: Buffer): void => { stderr += chunk.toString('utf8') }
|
||||||
|
const cleanup = (): void => {
|
||||||
|
child.stdout.off('data', onData)
|
||||||
|
child.stderr.off('data', onStderr)
|
||||||
|
child.off('error', onError)
|
||||||
|
child.off('exit', onExit)
|
||||||
|
}
|
||||||
|
child.stdout.on('data', onData)
|
||||||
|
child.stderr.on('data', onStderr)
|
||||||
|
child.once('error', onError)
|
||||||
|
child.once('exit', onExit)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/** Client-face process fixture used by Host-side protocol integration tests. */
|
||||||
|
|
||||||
|
import { parentPort, workerData } from 'node:worker_threads'
|
||||||
|
import { Context } from '@deepseek-ai/cordis'
|
||||||
|
import WebSocket from 'ws'
|
||||||
|
import { ClientInspectorSource } from '../../src/client/bridge/transport.ts'
|
||||||
|
import { ClientSourceCatalog } from '../../src/client/cdp/sources.ts'
|
||||||
|
import { publishCordisTree } from '../../src/client/inspection/cordis.ts'
|
||||||
|
import { inspectorId } from '../../src/shared/bridge/ids.ts'
|
||||||
|
import type { InspectorClientBootstrap } from '../../src/shared/bridge/messages/control.ts'
|
||||||
|
import type { InspectorJsonValue } from '../../src/shared/json.ts'
|
||||||
|
import { createInspectorService } from '../../src/shared/service.ts'
|
||||||
|
|
||||||
|
interface ClientFixtureInput {
|
||||||
|
readonly bootstrap: InspectorClientBootstrap
|
||||||
|
readonly label: string
|
||||||
|
readonly sourceCatalog?: {
|
||||||
|
readonly sourceText: string
|
||||||
|
readonly sourceMap: string
|
||||||
|
readonly sourceUrl: string
|
||||||
|
readonly sourceMapUrl: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClientFixtureRequest {
|
||||||
|
readonly id: number
|
||||||
|
readonly op: 'close' | 'disconnect' | 'get-tree' | 'log-cordis' | 'log-value' | 'publish' | 'set-global'
|
||||||
|
readonly name?: string
|
||||||
|
readonly value?: InspectorJsonValue
|
||||||
|
readonly marker?: string
|
||||||
|
readonly topic?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = parentPort
|
||||||
|
if (port === null) throw new Error('Inspector Client fixture requires a Worker parent port')
|
||||||
|
const input = workerData as ClientFixtureInput
|
||||||
|
globalThis.WebSocket = WebSocket as unknown as typeof globalThis.WebSocket
|
||||||
|
console.log = () => {}
|
||||||
|
|
||||||
|
const context = new Context()
|
||||||
|
const childFiber = context.plugin({ name: 'client-child', apply() {} })
|
||||||
|
await childFiber.await()
|
||||||
|
Reflect.set(globalThis, '__cordisClientProbe', context)
|
||||||
|
Reflect.set(globalThis, '__cordisClientFiberProbe', childFiber)
|
||||||
|
|
||||||
|
const sourceCatalog = input.sourceCatalog === undefined
|
||||||
|
? undefined
|
||||||
|
: new ClientSourceCatalog([{
|
||||||
|
scriptKey: inspectorId<'RuntimeScriptKey'>('bundle', 'scriptKey'),
|
||||||
|
url: input.sourceCatalog.sourceUrl,
|
||||||
|
hash: 'test',
|
||||||
|
sourceMapUrl: input.sourceCatalog.sourceMapUrl,
|
||||||
|
isModule: false,
|
||||||
|
loadSource: async () => input.sourceCatalog!.sourceText,
|
||||||
|
loadSourceMap: async () => input.sourceCatalog!.sourceMap,
|
||||||
|
}])
|
||||||
|
const source = new ClientInspectorSource(input.bootstrap, input.label, sourceCatalog)
|
||||||
|
const disposeCordis = publishCordisTree(context, source, {
|
||||||
|
maxNodes: input.bootstrap.maxCordisNodes,
|
||||||
|
maxBytes: input.bootstrap.maxFrameBytes - 4_096,
|
||||||
|
})
|
||||||
|
const service = createInspectorService(source)
|
||||||
|
|
||||||
|
port.on('message', (message: ClientFixtureRequest) => {
|
||||||
|
void dispatch(message).then(
|
||||||
|
(value) => {
|
||||||
|
port.postMessage({ type: 'response', id: message.id, ok: true, value })
|
||||||
|
if (message.op === 'close') port.close()
|
||||||
|
},
|
||||||
|
(error: unknown) => {
|
||||||
|
port.postMessage({
|
||||||
|
type: 'response',
|
||||||
|
id: message.id,
|
||||||
|
ok: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
port.postMessage({ type: 'ready', fiberUid: childFiber.uid })
|
||||||
|
|
||||||
|
async function dispatch(message: ClientFixtureRequest): Promise<unknown> {
|
||||||
|
switch (message.op) {
|
||||||
|
case 'publish':
|
||||||
|
source.publish(requiredString(message.topic, 'topic'), message.value ?? null)
|
||||||
|
return undefined
|
||||||
|
case 'set-global':
|
||||||
|
Reflect.set(globalThis, requiredString(message.name, 'name'), message.value)
|
||||||
|
return undefined
|
||||||
|
case 'log-value':
|
||||||
|
console.log(message.value, requiredString(message.marker, 'marker'))
|
||||||
|
return undefined
|
||||||
|
case 'log-cordis':
|
||||||
|
console.log(context, childFiber, requiredString(message.marker, 'marker'))
|
||||||
|
return undefined
|
||||||
|
case 'get-tree':
|
||||||
|
return await service.cordis.getTree()
|
||||||
|
case 'disconnect': {
|
||||||
|
const socket = Reflect.get(source, 'socket') as WebSocket | undefined
|
||||||
|
socket?.terminate()
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
case 'close':
|
||||||
|
disposeCordis()
|
||||||
|
source.close()
|
||||||
|
await context.fiber.dispose()
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredString(value: string | undefined, field: string): string {
|
||||||
|
if (value === undefined) throw new Error(`Inspector Client fixture ${field} is required`)
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
/** Host-side controller for the isolated Client test fixture. */
|
||||||
|
|
||||||
|
import { Worker } from 'node:worker_threads'
|
||||||
|
import type { InspectorClientBootstrap } from '../../src/shared/bridge/messages/control.ts'
|
||||||
|
import type { CordisRuntimeTree } from '../../src/shared/cordis/model.ts'
|
||||||
|
import type { InspectorJsonValue } from '../../src/shared/json.ts'
|
||||||
|
|
||||||
|
/** Optional source artifact exposed by the Client fixture. */
|
||||||
|
export interface ClientFixtureSourceCatalog {
|
||||||
|
readonly sourceText: string
|
||||||
|
readonly sourceMap: string
|
||||||
|
readonly sourceUrl: string
|
||||||
|
readonly sourceMapUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for one isolated Client fixture. */
|
||||||
|
export interface ClientFixtureOptions {
|
||||||
|
readonly label?: string
|
||||||
|
readonly sourceCatalog?: ClientFixtureSourceCatalog
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FixtureResponse {
|
||||||
|
readonly type: 'response'
|
||||||
|
readonly id: number
|
||||||
|
readonly ok: boolean
|
||||||
|
readonly value?: unknown
|
||||||
|
readonly error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A Client producer running outside the Host test realm. */
|
||||||
|
export class InspectorClientFixture {
|
||||||
|
private readonly worker: Worker
|
||||||
|
private readonly pending = new Map<number, PromiseWithResolvers<unknown>>()
|
||||||
|
private nextId = 0
|
||||||
|
private closed = false
|
||||||
|
readonly fiberUid: number
|
||||||
|
|
||||||
|
private constructor(worker: Worker, fiberUid: number) {
|
||||||
|
this.worker = worker
|
||||||
|
this.fiberUid = fiberUid
|
||||||
|
worker.on('message', (message: unknown) => { this.receive(message) })
|
||||||
|
worker.on('error', (error) => { this.fail(error) })
|
||||||
|
worker.on('exit', (code) => {
|
||||||
|
if (!this.closed && code !== 0) this.fail(new Error(`Inspector Client fixture exited with code ${String(code)}`))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start one Client fixture and wait for its Cordis tree to be published. */
|
||||||
|
static async start(
|
||||||
|
bootstrap: InspectorClientBootstrap,
|
||||||
|
options: ClientFixtureOptions = {},
|
||||||
|
): Promise<InspectorClientFixture> {
|
||||||
|
const ready = Promise.withResolvers<number>()
|
||||||
|
const entry = new URL('./client-source.client.ts', import.meta.url)
|
||||||
|
const tsxApi = import.meta.resolve('tsx/esm/api')
|
||||||
|
const source = `import { register } from ${JSON.stringify(tsxApi)}\nregister()\nawait import(${JSON.stringify(entry.href)})`
|
||||||
|
const worker = new Worker(new URL(`data:text/javascript,${encodeURIComponent(source)}`), {
|
||||||
|
execArgv: [],
|
||||||
|
workerData: {
|
||||||
|
bootstrap,
|
||||||
|
label: options.label ?? 'Test Client',
|
||||||
|
...(options.sourceCatalog === undefined ? {} : { sourceCatalog: options.sourceCatalog }),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const onMessage = (message: unknown): void => {
|
||||||
|
if (!isRecord(message) || message.type !== 'ready' || typeof message.fiberUid !== 'number') return
|
||||||
|
ready.resolve(message.fiberUid)
|
||||||
|
}
|
||||||
|
worker.on('message', onMessage)
|
||||||
|
worker.once('error', ready.reject)
|
||||||
|
const fiberUid = await ready.promise
|
||||||
|
worker.off('message', onMessage)
|
||||||
|
return new InspectorClientFixture(worker, fiberUid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Publish one observation from the Client realm. */
|
||||||
|
async publish(topic: string, value: InspectorJsonValue): Promise<void> {
|
||||||
|
await this.request({ op: 'publish', topic, value })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set one JSON-compatible global used by Client Runtime evaluation. */
|
||||||
|
async setGlobal(name: string, value: InspectorJsonValue): Promise<void> {
|
||||||
|
await this.request({ op: 'set-global', name, value })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emit one Console event carrying a caller-provided value. */
|
||||||
|
async log(value: InspectorJsonValue, marker: string): Promise<void> {
|
||||||
|
await this.request({ op: 'log-value', value, marker })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emit one Console event carrying the fixture's Context and Fiber. */
|
||||||
|
async logCordis(marker: string): Promise<void> {
|
||||||
|
await this.request({ op: 'log-cordis', marker })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the consumer-neutral Cordis tree through the Client service. */
|
||||||
|
async getCordisTree(): Promise<CordisRuntimeTree> {
|
||||||
|
return await this.request({ op: 'get-tree' }) as CordisRuntimeTree
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Break the active ingest socket while preserving the Client source. */
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
await this.request({ op: 'disconnect' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dispose the Client source and its Cordis context. */
|
||||||
|
async close(): Promise<void> {
|
||||||
|
if (this.closed) return
|
||||||
|
await this.request({ op: 'close' })
|
||||||
|
this.closed = true
|
||||||
|
await this.worker.terminate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request(fields: Record<string, unknown>): Promise<unknown> {
|
||||||
|
if (this.closed) throw new Error('Inspector Client fixture is closed')
|
||||||
|
const id = ++this.nextId
|
||||||
|
const result = Promise.withResolvers<unknown>()
|
||||||
|
this.pending.set(id, result)
|
||||||
|
this.worker.postMessage({ id, ...fields })
|
||||||
|
return await result.promise
|
||||||
|
}
|
||||||
|
|
||||||
|
private receive(message: unknown): void {
|
||||||
|
if (!isRecord(message) || message.type !== 'response' || typeof message.id !== 'number') return
|
||||||
|
const response = message as unknown as FixtureResponse
|
||||||
|
const pending = this.pending.get(response.id)
|
||||||
|
if (pending === undefined) return
|
||||||
|
this.pending.delete(response.id)
|
||||||
|
if (response.ok) pending.resolve(response.value)
|
||||||
|
else pending.reject(new Error(response.error ?? 'Inspector Client fixture request failed'))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fail(error: Error): void {
|
||||||
|
for (const pending of this.pending.values()) pending.reject(error)
|
||||||
|
this.pending.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/** Child-process fixture whose Host main thread is paused and resumed through the Inspector Worker. */
|
||||||
|
|
||||||
|
import { createInterface } from 'node:readline'
|
||||||
|
import { startInspector } from '../../src/host/bridge/controller.ts'
|
||||||
|
|
||||||
|
const inspector = await startInspector({ port: 0, captureFetch: false })
|
||||||
|
|
||||||
|
function breakpointProbe(value: number): number {
|
||||||
|
const local = value
|
||||||
|
return local + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, '__inspectorBreakpointProbe', { value: breakpointProbe, configurable: true })
|
||||||
|
process.stdout.write(`${JSON.stringify(inspector.endpoint)}\n`)
|
||||||
|
|
||||||
|
const input = createInterface({ input: process.stdin, terminal: false })
|
||||||
|
input.on('line', (line) => {
|
||||||
|
if (line === 'run') {
|
||||||
|
Object.defineProperty(globalThis, '__inspectorBreakpointResult', {
|
||||||
|
value: breakpointProbe(41),
|
||||||
|
configurable: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (line === 'stop') {
|
||||||
|
input.close()
|
||||||
|
void inspector.close().then(() => { process.exit(0) })
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,545 @@
|
|||||||
|
/** Host-driven integration over an isolated Client fixture. */
|
||||||
|
|
||||||
|
import { createServer, type Server } from 'node:http'
|
||||||
|
import WebSocket, { type RawData } from 'ws'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { startInspector, type InspectorHandle } from '../src/host/bridge/controller.ts'
|
||||||
|
import { InspectorClientFixture } from './fixtures/client-source.host.ts'
|
||||||
|
|
||||||
|
interface CdpMessage {
|
||||||
|
readonly id?: number
|
||||||
|
readonly method?: string
|
||||||
|
readonly params?: Record<string, unknown>
|
||||||
|
readonly result?: Record<string, unknown>
|
||||||
|
readonly error?: { message: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
class TestCdpClient {
|
||||||
|
private nextId = 0
|
||||||
|
private readonly pending = new Map<number, (message: CdpMessage) => void>()
|
||||||
|
readonly events: CdpMessage[] = []
|
||||||
|
|
||||||
|
private constructor(private readonly socket: WebSocket) {
|
||||||
|
socket.on('message', (data) => {
|
||||||
|
const message = JSON.parse(rawText(data)) as CdpMessage
|
||||||
|
if (message.id !== undefined) this.pending.get(message.id)?.(message)
|
||||||
|
else this.events.push(message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
static async connect(url: string): Promise<TestCdpClient> {
|
||||||
|
const socket = new WebSocket(url)
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
socket.once('open', () => { resolve() })
|
||||||
|
socket.once('error', reject)
|
||||||
|
})
|
||||||
|
return new TestCdpClient(socket)
|
||||||
|
}
|
||||||
|
|
||||||
|
call(method: string, params: Record<string, unknown> = {}): Promise<CdpMessage> {
|
||||||
|
const id = ++this.nextId
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.pending.delete(id)
|
||||||
|
reject(new Error(`CDP call timed out: ${method}`))
|
||||||
|
}, 5_000)
|
||||||
|
this.pending.set(id, (message) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
this.pending.delete(id)
|
||||||
|
resolve(message)
|
||||||
|
})
|
||||||
|
this.socket.send(JSON.stringify({ id, method, params }))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
if (this.socket.readyState === WebSocket.CLOSED) return
|
||||||
|
const closed = new Promise<void>((resolve) => { this.socket.once('close', () => { resolve() }) })
|
||||||
|
this.socket.close()
|
||||||
|
await closed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('experimental Inspector real Worker', () => {
|
||||||
|
let inspector: InspectorHandle | undefined
|
||||||
|
let cdp: TestCdpClient | undefined
|
||||||
|
let secondCdp: TestCdpClient | undefined
|
||||||
|
let client: InspectorClientFixture | undefined
|
||||||
|
let server: Server | undefined
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await client?.close()
|
||||||
|
client = undefined
|
||||||
|
await cdp?.close()
|
||||||
|
cdp = undefined
|
||||||
|
await secondCdp?.close()
|
||||||
|
secondCdp = undefined
|
||||||
|
await inspector?.close()
|
||||||
|
inspector = undefined
|
||||||
|
if (server !== undefined) await new Promise<void>((resolve) => { server!.close(() => { resolve() }) })
|
||||||
|
server = undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
it('switches between Host and Client contexts and routes Client RemoteObjects', async () => {
|
||||||
|
inspector = await startInspector({ port: 0, captureFetch: false, clientReconnectBaseMs: 10, clientReconnectMaxMs: 20 })
|
||||||
|
cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
|
||||||
|
inspector.source.publish('host/probe', { value: 1 })
|
||||||
|
client = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Test Client' })
|
||||||
|
await client.publish('client/probe', { value: 2 })
|
||||||
|
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
const response = await cdp!.call('DSHInspector.getSources')
|
||||||
|
const sources = response.result?.sources as Array<{ kind: string; topics: Record<string, number> }>
|
||||||
|
expect(sources).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ kind: 'host', topics: { 'host/probe': 1 } }),
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: 'client',
|
||||||
|
topics: expect.objectContaining({ 'client/probe': 1 }),
|
||||||
|
}),
|
||||||
|
]))
|
||||||
|
})
|
||||||
|
|
||||||
|
;(globalThis as Record<string, unknown>).__inspectorHostProbe = 73
|
||||||
|
expect((await cdp.call('Runtime.enable')).error).toBeUndefined()
|
||||||
|
let clientContextId: number | undefined
|
||||||
|
let clientUniqueContextId: string | undefined
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(runtimeContexts(cdp!).some(context => context.name === 'Host')).toBe(true)
|
||||||
|
const clientContext = cdp!.events
|
||||||
|
.filter(event => event.method === 'Runtime.executionContextCreated')
|
||||||
|
.map(event => event.params?.context as Record<string, unknown> | undefined)
|
||||||
|
.find(context => String(context?.name).startsWith('Client —'))
|
||||||
|
expect(clientContext).toBeDefined()
|
||||||
|
clientContextId = clientContext?.id as number
|
||||||
|
clientUniqueContextId = clientContext?.uniqueId as string
|
||||||
|
})
|
||||||
|
if (clientContextId === undefined || clientUniqueContextId === undefined) {
|
||||||
|
throw new Error('Client execution context was not announced')
|
||||||
|
}
|
||||||
|
const hostEvaluated = await cdp.call('Runtime.evaluate', {
|
||||||
|
expression: 'globalThis.__inspectorHostProbe',
|
||||||
|
returnByValue: true,
|
||||||
|
})
|
||||||
|
expect(hostEvaluated.result?.result).toMatchObject({ type: 'number', value: 73 })
|
||||||
|
|
||||||
|
await client.setGlobal('__inspectorClientProbe', { value: 17, nested: { ready: true } })
|
||||||
|
const clientEvaluated = await cdp.call('Runtime.evaluate', {
|
||||||
|
expression: 'globalThis.__inspectorClientProbe',
|
||||||
|
contextId: clientContextId,
|
||||||
|
objectGroup: 'console',
|
||||||
|
generatePreview: true,
|
||||||
|
})
|
||||||
|
const clientObject = clientEvaluated.result?.result as Record<string, unknown>
|
||||||
|
expect(clientObject).toMatchObject({ type: 'object', className: 'Object' })
|
||||||
|
expect(String(clientObject.objectId)).toMatch(/^runtime:/u)
|
||||||
|
|
||||||
|
const properties = await cdp.call('Runtime.getProperties', {
|
||||||
|
objectId: clientObject.objectId,
|
||||||
|
ownProperties: true,
|
||||||
|
})
|
||||||
|
const propertyRows = recordArray(properties.result?.result)
|
||||||
|
const valueProperty = propertyRows.find(property => property.name === 'value')
|
||||||
|
const nestedProperty = propertyRows.find(property => property.name === 'nested')
|
||||||
|
expect(asRecord(valueProperty?.value)).toMatchObject({ type: 'number', value: 17 })
|
||||||
|
expect(asRecord(nestedProperty?.value).type).toBe('object')
|
||||||
|
|
||||||
|
const called = await cdp.call('Runtime.callFunctionOn', {
|
||||||
|
objectId: clientObject.objectId,
|
||||||
|
functionDeclaration: 'function (increment) { return this.value + increment }',
|
||||||
|
arguments: [{ value: 5 }],
|
||||||
|
returnByValue: true,
|
||||||
|
})
|
||||||
|
expect(called.result?.result).toMatchObject({ type: 'number', value: 22 })
|
||||||
|
|
||||||
|
const hostObject = await cdp.call('Runtime.evaluate', { expression: '({ realm: "host" })' })
|
||||||
|
const hostObjectId = asRecord(hostObject.result?.result).objectId
|
||||||
|
expect((await cdp.call('Runtime.callFunctionOn', {
|
||||||
|
executionContextId: clientContextId,
|
||||||
|
functionDeclaration: 'function (value) { return value }',
|
||||||
|
arguments: [{ objectId: hostObjectId }],
|
||||||
|
})).error?.message).toContain('between realms')
|
||||||
|
expect((await cdp.call('Runtime.callFunctionOn', {
|
||||||
|
objectId: hostObjectId,
|
||||||
|
functionDeclaration: 'function (value) { return value }',
|
||||||
|
arguments: [{ objectId: clientObject.objectId }],
|
||||||
|
})).error?.message).toContain('between realms')
|
||||||
|
expect((await cdp.call('Runtime.queryObjects', {
|
||||||
|
prototypeObjectId: clientObject.objectId,
|
||||||
|
})).error?.message).toContain('Client realm has no native CDP transport')
|
||||||
|
|
||||||
|
const awaited = await cdp.call('Runtime.evaluate', {
|
||||||
|
expression: 'Promise.resolve({ realm: "client" })',
|
||||||
|
contextId: clientContextId,
|
||||||
|
awaitPromise: true,
|
||||||
|
returnByValue: true,
|
||||||
|
})
|
||||||
|
expect(awaited.result?.result).toMatchObject({ type: 'object', value: { realm: 'client' } })
|
||||||
|
|
||||||
|
const uniquelyRouted = await cdp.call('Runtime.evaluate', {
|
||||||
|
expression: '6 * 7',
|
||||||
|
uniqueContextId: clientUniqueContextId,
|
||||||
|
returnByValue: true,
|
||||||
|
})
|
||||||
|
expect(uniquelyRouted.result?.result).toMatchObject({ type: 'number', value: 42 })
|
||||||
|
|
||||||
|
expect((await cdp.call('Runtime.releaseObject', { objectId: clientObject.objectId })).error).toBeUndefined()
|
||||||
|
expect((await cdp.call('Runtime.getProperties', { objectId: clientObject.objectId })).error).toBeDefined()
|
||||||
|
|
||||||
|
const thrown = await cdp.call('Runtime.evaluate', {
|
||||||
|
expression: 'throw new Error("client failure")',
|
||||||
|
contextId: clientContextId,
|
||||||
|
})
|
||||||
|
expect(asRecord(thrown.result?.exceptionDetails)).toMatchObject({
|
||||||
|
text: 'Uncaught',
|
||||||
|
executionContextId: clientContextId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const pendingEvaluation = cdp.call('Runtime.evaluate', {
|
||||||
|
expression: 'new Promise(() => {})',
|
||||||
|
contextId: clientContextId,
|
||||||
|
awaitPromise: true,
|
||||||
|
})
|
||||||
|
await new Promise<void>((resolve) => { setTimeout(resolve, 10) })
|
||||||
|
await client.close()
|
||||||
|
client = undefined
|
||||||
|
expect((await pendingEvaluation).error).toBeDefined()
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(cdp!.events.some(event =>
|
||||||
|
event.method === 'Runtime.executionContextDestroyed'
|
||||||
|
&& event.params?.executionContextId === clientContextId)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('isolates Client object ids and object groups by DevTools connection', async () => {
|
||||||
|
inspector = await startInspector({ port: 0, captureFetch: false })
|
||||||
|
client = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Shared Client' })
|
||||||
|
cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
|
||||||
|
secondCdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
|
||||||
|
await Promise.all([cdp.call('Runtime.enable'), secondCdp.call('Runtime.enable')])
|
||||||
|
|
||||||
|
const firstContext = await clientContext(cdp)
|
||||||
|
const secondContext = await clientContext(secondCdp)
|
||||||
|
const first = await cdp.call('Runtime.evaluate', {
|
||||||
|
expression: '({ owner: "first" })',
|
||||||
|
contextId: firstContext,
|
||||||
|
objectGroup: 'console',
|
||||||
|
})
|
||||||
|
const second = await secondCdp.call('Runtime.evaluate', {
|
||||||
|
expression: '({ owner: "second" })',
|
||||||
|
contextId: secondContext,
|
||||||
|
objectGroup: 'console',
|
||||||
|
})
|
||||||
|
const firstObjectId = asRecord(first.result?.result).objectId
|
||||||
|
const secondObjectId = asRecord(second.result?.result).objectId
|
||||||
|
expect(firstObjectId).not.toBe(secondObjectId)
|
||||||
|
expect((await secondCdp.call('Runtime.getProperties', { objectId: firstObjectId })).error).toBeDefined()
|
||||||
|
|
||||||
|
await cdp.close()
|
||||||
|
cdp = undefined
|
||||||
|
const secondProperties = await secondCdp.call('Runtime.getProperties', {
|
||||||
|
objectId: secondObjectId,
|
||||||
|
ownProperties: true,
|
||||||
|
})
|
||||||
|
const owner = recordArray(secondProperties.result?.result).find(property => property.name === 'owner')
|
||||||
|
expect(asRecord(owner?.value).value).toBe('second')
|
||||||
|
expect((await secondCdp.call('Runtime.releaseObjectGroup', { objectGroup: 'console' })).error).toBeUndefined()
|
||||||
|
expect((await secondCdp.call('Runtime.getProperties', { objectId: secondObjectId })).error).toBeDefined()
|
||||||
|
|
||||||
|
const beforeDisable = await secondCdp.call('Runtime.evaluate', {
|
||||||
|
expression: '({ retained: true })',
|
||||||
|
contextId: secondContext,
|
||||||
|
})
|
||||||
|
const disabledObjectId = asRecord(beforeDisable.result?.result).objectId
|
||||||
|
expect((await secondCdp.call('Runtime.disable')).error).toBeUndefined()
|
||||||
|
expect((await secondCdp.call('Runtime.enable')).error).toBeUndefined()
|
||||||
|
expect((await secondCdp.call('Runtime.getProperties', { objectId: disabledObjectId })).error).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the same Runtime value model for Host and Client realms', async () => {
|
||||||
|
inspector = await startInspector({ port: 0, captureFetch: false })
|
||||||
|
client = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Compatibility Client' })
|
||||||
|
cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
|
||||||
|
await cdp.call('Runtime.enable')
|
||||||
|
const clientContextId = await clientContext(cdp)
|
||||||
|
|
||||||
|
for (const [name, contextId] of [['Host', undefined], ['Client', clientContextId]] as const) {
|
||||||
|
const select = contextId === undefined ? {} : { contextId }
|
||||||
|
const nan = await cdp.call('Runtime.evaluate', { expression: 'NaN', ...select })
|
||||||
|
expect(nan.result?.result, name).toMatchObject({ type: 'number', unserializableValue: 'NaN' })
|
||||||
|
|
||||||
|
const array = await cdp.call('Runtime.evaluate', {
|
||||||
|
expression: '[1, 2]',
|
||||||
|
objectGroup: `compat-${name}`,
|
||||||
|
...select,
|
||||||
|
})
|
||||||
|
const arrayObject = asRecord(array.result?.result)
|
||||||
|
expect(arrayObject, name).toMatchObject({ type: 'object', subtype: 'array', className: 'Array' })
|
||||||
|
const properties = await cdp.call('Runtime.getProperties', {
|
||||||
|
objectId: arrayObject.objectId,
|
||||||
|
ownProperties: true,
|
||||||
|
})
|
||||||
|
const first = recordArray(properties.result?.result).find(property => property.name === '0')
|
||||||
|
expect(first, name).toMatchObject({ configurable: true, enumerable: true, writable: true })
|
||||||
|
expect(asRecord(first?.value), name).toMatchObject({ type: 'number', value: 1 })
|
||||||
|
|
||||||
|
const thrown = await cdp.call('Runtime.evaluate', {
|
||||||
|
expression: 'throw new TypeError("realm-compatibility")',
|
||||||
|
...select,
|
||||||
|
})
|
||||||
|
expect(thrown.result?.result, name).toMatchObject({ type: 'object', subtype: 'error' })
|
||||||
|
expect(thrown.result?.exceptionDetails, name).toMatchObject({ text: 'Uncaught' })
|
||||||
|
|
||||||
|
expect((await cdp.call('Runtime.releaseObjectGroup', { objectGroup: `compat-${name}` })).error).toBeUndefined()
|
||||||
|
expect((await cdp.call('Runtime.getProperties', { objectId: arrayObject.objectId })).error).toBeDefined()
|
||||||
|
}
|
||||||
|
|
||||||
|
expect((await cdp.call('Runtime.evaluate', {
|
||||||
|
expression: '1 + 1',
|
||||||
|
throwOnSideEffect: true,
|
||||||
|
})).result?.result).toMatchObject({ type: 'number', value: 2 })
|
||||||
|
expect((await cdp.call('Runtime.evaluate', {
|
||||||
|
expression: '1 + 1',
|
||||||
|
contextId: clientContextId,
|
||||||
|
throwOnSideEffect: true,
|
||||||
|
})).error?.message).toContain('does not support throwOnSideEffect')
|
||||||
|
expect((await cdp.call('Runtime.compileScript', {
|
||||||
|
expression: '1 + 1',
|
||||||
|
sourceURL: 'client-eval.js',
|
||||||
|
persistScript: true,
|
||||||
|
executionContextId: clientContextId,
|
||||||
|
})).error?.message).toContain('Client realm has no native CDP transport')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('forwards Client Console objects through isolated realm sessions', async () => {
|
||||||
|
inspector = await startInspector({ port: 0, captureFetch: false })
|
||||||
|
client = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Console Client' })
|
||||||
|
cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
|
||||||
|
secondCdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
|
||||||
|
await Promise.all([cdp.call('Runtime.enable'), secondCdp.call('Runtime.enable')])
|
||||||
|
const firstContext = await clientContext(cdp)
|
||||||
|
const secondContext = await clientContext(secondCdp)
|
||||||
|
const value = { owner: 'client-console' }
|
||||||
|
const marker = 'client-console-event'
|
||||||
|
await client.log(value, marker)
|
||||||
|
let firstEvent: CdpMessage | undefined
|
||||||
|
let secondEvent: CdpMessage | undefined
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
firstEvent = consoleEvent(cdp!, firstContext, marker)
|
||||||
|
secondEvent = consoleEvent(secondCdp!, secondContext, marker)
|
||||||
|
expect(firstEvent).toBeDefined()
|
||||||
|
expect(secondEvent).toBeDefined()
|
||||||
|
})
|
||||||
|
const firstObjectId = asRecord(recordArray(firstEvent!.params?.args)[0]).objectId
|
||||||
|
const secondObjectId = asRecord(recordArray(secondEvent!.params?.args)[0]).objectId
|
||||||
|
expect(firstObjectId).toBeTypeOf('string')
|
||||||
|
expect(secondObjectId).toBeTypeOf('string')
|
||||||
|
expect(firstObjectId).not.toBe(secondObjectId)
|
||||||
|
expect((await secondCdp.call('Runtime.getProperties', { objectId: firstObjectId })).error).toBeDefined()
|
||||||
|
|
||||||
|
const secondProperties = await secondCdp.call('Runtime.getProperties', {
|
||||||
|
objectId: secondObjectId,
|
||||||
|
ownProperties: true,
|
||||||
|
})
|
||||||
|
const owner = recordArray(secondProperties.result?.result).find(property => property.name === 'owner')
|
||||||
|
expect(asRecord(owner?.value).value).toBe('client-console')
|
||||||
|
|
||||||
|
expect((await cdp.call('Runtime.discardConsoleEntries')).error).toBeUndefined()
|
||||||
|
expect((await cdp.call('Runtime.getProperties', { objectId: firstObjectId })).error).toBeDefined()
|
||||||
|
expect((await secondCdp.call('Runtime.getProperties', { objectId: secondObjectId })).error).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('projects a chunked Client bundle as read-only Debugger source', async () => {
|
||||||
|
const sourceText = `const clientSourceMarker = 42\n/*${'x'.repeat(150_000)}*/\n`
|
||||||
|
const sourceMap = JSON.stringify({ version: 3, sources: ['client/index.ts'], mappings: 'AAAA' })
|
||||||
|
const sourceUrl = 'http://client.test/plugins/inspector/client.js?rev=test'
|
||||||
|
const sourceMapUrl = 'http://client.test/plugins/inspector/client.js.map?rev=test'
|
||||||
|
inspector = await startInspector({ port: 0, captureFetch: false, maxClientSourceBytes: 1_000_000 })
|
||||||
|
client = await InspectorClientFixture.start(inspector.endpoint.client, {
|
||||||
|
label: 'Source Client',
|
||||||
|
sourceCatalog: { sourceText, sourceMap, sourceUrl, sourceMapUrl },
|
||||||
|
})
|
||||||
|
cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
|
||||||
|
await cdp.call('Runtime.enable')
|
||||||
|
const contextId = await clientContext(cdp)
|
||||||
|
expect((await cdp.call('Debugger.enable')).error).toBeUndefined()
|
||||||
|
|
||||||
|
let script: CdpMessage | undefined
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
script = cdp!.events.find(event => event.method === 'Debugger.scriptParsed'
|
||||||
|
&& event.params?.url === sourceUrl)
|
||||||
|
expect(script).toBeDefined()
|
||||||
|
})
|
||||||
|
expect(script?.params).toMatchObject({
|
||||||
|
executionContextId: contextId,
|
||||||
|
sourceMapURL: sourceMapUrl,
|
||||||
|
hash: 'test',
|
||||||
|
isModule: false,
|
||||||
|
length: sourceText.length,
|
||||||
|
})
|
||||||
|
const scriptId = script?.params?.scriptId
|
||||||
|
expect(scriptId).toBeTypeOf('string')
|
||||||
|
await expect(cdp.call('Debugger.getScriptSource', { scriptId })).resolves.toMatchObject({
|
||||||
|
result: { scriptSource: sourceText },
|
||||||
|
})
|
||||||
|
await expect(cdp.call('Debugger.searchInContent', {
|
||||||
|
scriptId,
|
||||||
|
query: 'clientSourceMarker',
|
||||||
|
caseSensitive: true,
|
||||||
|
})).resolves.toMatchObject({
|
||||||
|
result: { result: [{ lineNumber: 0, lineContent: 'const clientSourceMarker = 42' }] },
|
||||||
|
})
|
||||||
|
expect((await cdp.call('Debugger.setBreakpointByUrl', { url: sourceUrl, lineNumber: 0 })).error?.message)
|
||||||
|
.toContain('Client native debugging is unavailable')
|
||||||
|
expect((await cdp.call('Debugger.setBreakpointByUrl', {
|
||||||
|
urlRegex: 'client\\.js',
|
||||||
|
lineNumber: 0,
|
||||||
|
})).error?.message).toContain('Client native debugging is unavailable')
|
||||||
|
expect((await cdp.call('Debugger.setBreakpointByUrl', {
|
||||||
|
scriptHash: 'test',
|
||||||
|
lineNumber: 0,
|
||||||
|
})).error?.message).toContain('Client native debugging is unavailable')
|
||||||
|
expect((await cdp.call('Debugger.evaluateOnCallFrame', {
|
||||||
|
callFrameId: 'client:unsupported-frame',
|
||||||
|
expression: '1',
|
||||||
|
})).error?.message).toContain('Client native debugging is unavailable')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('projects full Host fetch data through the Network domain', async () => {
|
||||||
|
server = createServer((request, response) => {
|
||||||
|
let body = ''
|
||||||
|
request.setEncoding('utf8')
|
||||||
|
request.on('data', (chunk: string) => { body += chunk })
|
||||||
|
request.on('end', () => {
|
||||||
|
response.writeHead(201, { authorization: 'response-secret', 'content-type': 'application/json' })
|
||||||
|
response.end(JSON.stringify({ body }))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await new Promise<void>((resolve) => { server!.listen(0, '127.0.0.1', () => { resolve() }) })
|
||||||
|
const port = (server.address() as import('node:net').AddressInfo).port
|
||||||
|
inspector = await startInspector({ port: 0 })
|
||||||
|
cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
|
||||||
|
await cdp.call('Network.enable')
|
||||||
|
|
||||||
|
const response = await fetch(`http://127.0.0.1:${String(port)}/capture?secret=query`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { authorization: 'Bearer request-secret' },
|
||||||
|
body: 'request-body',
|
||||||
|
})
|
||||||
|
expect(await response.json()).toEqual({ body: 'request-body' })
|
||||||
|
|
||||||
|
let started: CdpMessage | undefined
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
started = cdp!.events.find(event =>
|
||||||
|
event.method === 'Network.requestWillBeSent'
|
||||||
|
&& String((event.params?.request as Record<string, unknown> | undefined)?.url).includes('/capture'))
|
||||||
|
expect(started).toBeDefined()
|
||||||
|
expect(cdp!.events.some(event =>
|
||||||
|
event.method === 'Network.loadingFinished'
|
||||||
|
&& event.params?.requestId === started!.params?.requestId)).toBe(true)
|
||||||
|
})
|
||||||
|
const request = started!.params?.request as Record<string, unknown>
|
||||||
|
expect(request.url).toBe(`http://127.0.0.1:${String(port)}/capture?secret=query`)
|
||||||
|
expect(request.headers).toMatchObject({ authorization: 'Bearer request-secret' })
|
||||||
|
const requestId = started!.params?.requestId
|
||||||
|
const post = await cdp.call('Network.getRequestPostData', { requestId })
|
||||||
|
expect(post.result?.postData).toBe('request-body')
|
||||||
|
const body = await cdp.call('Network.getResponseBody', { requestId })
|
||||||
|
expect(Buffer.from(String(body.result?.body), 'base64').toString('utf8')).toBe('{"body":"request-body"}')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('streams later Host fetch response chunks to an opted-in CDP connection', async () => {
|
||||||
|
const continueResponse = Promise.withResolvers<true>()
|
||||||
|
const firstChunk = 'data: first\n\n'
|
||||||
|
const laterChunk = 'event: update\nid: 2\ndata: second\ndata: line\n\n'
|
||||||
|
server = createServer((_request, response) => {
|
||||||
|
response.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8' })
|
||||||
|
response.write(firstChunk)
|
||||||
|
void continueResponse.promise.then(() => { response.end(laterChunk) })
|
||||||
|
})
|
||||||
|
await new Promise<void>((resolve) => { server!.listen(0, '127.0.0.1', () => { resolve() }) })
|
||||||
|
const port = (server.address() as import('node:net').AddressInfo).port
|
||||||
|
inspector = await startInspector({ port: 0 })
|
||||||
|
cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
|
||||||
|
await cdp.call('Network.enable')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://127.0.0.1:${String(port)}/events`)
|
||||||
|
let requestId: string | undefined
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const received = cdp!.events.find(event =>
|
||||||
|
event.method === 'Network.responseReceived'
|
||||||
|
&& (event.params?.response as Record<string, unknown> | undefined)?.mimeType === 'text/event-stream')
|
||||||
|
requestId = received?.params?.requestId as string | undefined
|
||||||
|
expect(requestId).toBeTypeOf('string')
|
||||||
|
expect(cdp!.events.some(event =>
|
||||||
|
event.method === 'Network.dataReceived'
|
||||||
|
&& event.params?.requestId === requestId)).toBe(true)
|
||||||
|
})
|
||||||
|
if (requestId === undefined) throw new Error('SSE request was not observed')
|
||||||
|
|
||||||
|
const streaming = await cdp.call('Network.streamResourceContent', { requestId })
|
||||||
|
expect(Buffer.from(String(streaming.result?.bufferedData), 'base64').toString('utf8')).toBe(firstChunk)
|
||||||
|
const laterEventOffset = cdp.events.length
|
||||||
|
continueResponse.resolve(true)
|
||||||
|
expect(await response.text()).toBe(firstChunk + laterChunk)
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(cdp!.events.some(event =>
|
||||||
|
event.method === 'Network.loadingFinished'
|
||||||
|
&& event.params?.requestId === requestId)).toBe(true)
|
||||||
|
const streamed = cdp!.events.slice(laterEventOffset)
|
||||||
|
.filter(event => event.method === 'Network.dataReceived'
|
||||||
|
&& event.params?.requestId === requestId
|
||||||
|
&& typeof event.params?.data === 'string')
|
||||||
|
.map(event => Buffer.from(String(event.params!.data), 'base64'))
|
||||||
|
expect(Buffer.concat(streamed).toString('utf8')).toBe(laterChunk)
|
||||||
|
})
|
||||||
|
|
||||||
|
const body = await cdp.call('Network.getResponseBody', { requestId })
|
||||||
|
expect(Buffer.from(String(body.result?.body), 'base64').toString('utf8')).toBe(firstChunk + laterChunk)
|
||||||
|
} finally {
|
||||||
|
continueResponse.resolve(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
async function clientContext(client: TestCdpClient): Promise<number> {
|
||||||
|
let contextId: number | undefined
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const context = runtimeContexts(client).find(candidate => String(candidate.name).startsWith('Client —'))
|
||||||
|
expect(context).toBeDefined()
|
||||||
|
contextId = context?.id as number
|
||||||
|
})
|
||||||
|
if (contextId === undefined) throw new Error('Client execution context was not announced')
|
||||||
|
return contextId
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeContexts(client: TestCdpClient): Readonly<Record<string, unknown>>[] {
|
||||||
|
return client.events
|
||||||
|
.filter(event => event.method === 'Runtime.executionContextCreated')
|
||||||
|
.map(event => asRecord(event.params?.context))
|
||||||
|
}
|
||||||
|
|
||||||
|
function consoleEvent(client: TestCdpClient, contextId: number, marker: string): CdpMessage | undefined {
|
||||||
|
return client.events.find((event) => {
|
||||||
|
if (event.method !== 'Runtime.consoleAPICalled' || event.params?.executionContextId !== contextId) return false
|
||||||
|
const args = event.params.args
|
||||||
|
return Array.isArray(args) && args.some(argument => asRecord(argument).value === marker)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordArray(value: unknown): Readonly<Record<string, unknown>>[] {
|
||||||
|
if (!Array.isArray(value)) throw new Error('expected an array of records')
|
||||||
|
return value.map(asRecord)
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Readonly<Record<string, unknown>> {
|
||||||
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('expected a record')
|
||||||
|
return value as Readonly<Record<string, unknown>>
|
||||||
|
}
|
||||||
|
|
||||||
|
function rawText(data: RawData): string {
|
||||||
|
if (Array.isArray(data)) return Buffer.concat(data).toString('utf8')
|
||||||
|
if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8')
|
||||||
|
return Buffer.from(data).toString('utf8')
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user