feat(inspector): serve Runtime through a CDP Worker

This commit is contained in:
imccyu
2026-08-27 16:15:18 +08:00
parent 19f0076668
commit bcee99e39d
48 changed files with 6661 additions and 0 deletions
@@ -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')
}