From bcee99e39d29006e1b2391b68d6bc82d5175da26 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:26:09 +0800 Subject: [PATCH] feat(inspector): serve Runtime through a CDP Worker --- .../inspector/src/worker/bridge/endpoint.ts | 304 ++++++++++ .../inspector/src/worker/bridge/hub.ts | 316 ++++++++++ .../src/worker/bridge/runtime-rpc.ts | 328 +++++++++++ .../inspector/src/worker/bridge/session.ts | 26 + .../inspector/src/worker/bridge/source-rpc.ts | 192 ++++++ .../worker/cdp/domains/debugger/cdp-params.ts | 52 ++ .../src/worker/cdp/domains/debugger/index.ts | 6 + .../worker/cdp/domains/debugger/projector.ts | 114 ++++ .../cdp/domains/debugger/script-registry.ts | 117 ++++ .../worker/cdp/domains/debugger/session.ts | 350 +++++++++++ .../src/worker/cdp/domains/native.ts | 49 ++ .../worker/cdp/domains/runtime/cdp-params.ts | 256 ++++++++ .../src/worker/cdp/domains/runtime/index.ts | 3 + .../cdp/domains/runtime/object-table.ts | 323 +++++++++++ .../src/worker/cdp/domains/runtime/session.ts | 455 +++++++++++++++ .../inspector/src/worker/cdp/ids.ts | 53 ++ .../inspector/src/worker/cdp/protocol.ts | 82 +++ .../src/worker/cdp/realm-sessions.ts | 128 ++++ .../inspector/src/worker/cdp/session.ts | 117 ++++ .../inspector/src/worker/cdp/target.ts | 77 +++ .../inspector/src/worker/entry.ts | 55 ++ .../src/worker/inspection/realm-store.ts | 116 ++++ .../inspector/src/worker/inspection/realm.ts | 54 ++ .../src/worker/realms/client/bridge.ts | 26 + .../src/worker/realms/client/console.ts | 40 ++ .../src/worker/realms/client/debugger.ts | 11 + .../src/worker/realms/client/index.ts | 104 ++++ .../src/worker/realms/client/runtime.ts | 160 +++++ .../src/worker/realms/client/scripts.ts | 27 + .../src/worker/realms/client/sources.ts | 122 ++++ .../src/worker/realms/client/values.ts | 162 ++++++ .../src/worker/realms/host/bridge.ts | 164 ++++++ .../src/worker/realms/host/console.ts | 90 +++ .../src/worker/realms/host/debugger.ts | 167 ++++++ .../inspector/src/worker/realms/host/index.ts | 67 +++ .../src/worker/realms/host/runtime.ts | 322 +++++++++++ .../src/worker/realms/host/scripts.ts | 13 + .../src/worker/realms/host/sources.ts | 99 ++++ .../src/worker/realms/host/values.ts | 34 ++ .../inspector/src/worker/server.ts | 111 ++++ .../inspector/tests/built-lib.e2e.ts | 56 ++ .../inspector/tests/client-browser.e2e.ts | 257 +++++++++ .../inspector/tests/client-stack.host.spec.ts | 30 + .../inspector/tests/debugger.e2e.ts | 198 +++++++ .../tests/fixtures/client-source.client.ts | 114 ++++ .../tests/fixtures/client-source.host.ts | 141 +++++ .../inspector/tests/fixtures/debug-host.ts | 28 + .../inspector/tests/integration.host.spec.ts | 545 ++++++++++++++++++ 48 files changed, 6661 insertions(+) create mode 100644 packages/experimental/inspector/src/worker/bridge/endpoint.ts create mode 100644 packages/experimental/inspector/src/worker/bridge/hub.ts create mode 100644 packages/experimental/inspector/src/worker/bridge/runtime-rpc.ts create mode 100644 packages/experimental/inspector/src/worker/bridge/session.ts create mode 100644 packages/experimental/inspector/src/worker/bridge/source-rpc.ts create mode 100644 packages/experimental/inspector/src/worker/cdp/domains/debugger/cdp-params.ts create mode 100644 packages/experimental/inspector/src/worker/cdp/domains/debugger/index.ts create mode 100644 packages/experimental/inspector/src/worker/cdp/domains/debugger/projector.ts create mode 100644 packages/experimental/inspector/src/worker/cdp/domains/debugger/script-registry.ts create mode 100644 packages/experimental/inspector/src/worker/cdp/domains/debugger/session.ts create mode 100644 packages/experimental/inspector/src/worker/cdp/domains/native.ts create mode 100644 packages/experimental/inspector/src/worker/cdp/domains/runtime/cdp-params.ts create mode 100644 packages/experimental/inspector/src/worker/cdp/domains/runtime/index.ts create mode 100644 packages/experimental/inspector/src/worker/cdp/domains/runtime/object-table.ts create mode 100644 packages/experimental/inspector/src/worker/cdp/domains/runtime/session.ts create mode 100644 packages/experimental/inspector/src/worker/cdp/ids.ts create mode 100644 packages/experimental/inspector/src/worker/cdp/protocol.ts create mode 100644 packages/experimental/inspector/src/worker/cdp/realm-sessions.ts create mode 100644 packages/experimental/inspector/src/worker/cdp/session.ts create mode 100644 packages/experimental/inspector/src/worker/cdp/target.ts create mode 100644 packages/experimental/inspector/src/worker/entry.ts create mode 100644 packages/experimental/inspector/src/worker/inspection/realm-store.ts create mode 100644 packages/experimental/inspector/src/worker/inspection/realm.ts create mode 100644 packages/experimental/inspector/src/worker/realms/client/bridge.ts create mode 100644 packages/experimental/inspector/src/worker/realms/client/console.ts create mode 100644 packages/experimental/inspector/src/worker/realms/client/debugger.ts create mode 100644 packages/experimental/inspector/src/worker/realms/client/index.ts create mode 100644 packages/experimental/inspector/src/worker/realms/client/runtime.ts create mode 100644 packages/experimental/inspector/src/worker/realms/client/scripts.ts create mode 100644 packages/experimental/inspector/src/worker/realms/client/sources.ts create mode 100644 packages/experimental/inspector/src/worker/realms/client/values.ts create mode 100644 packages/experimental/inspector/src/worker/realms/host/bridge.ts create mode 100644 packages/experimental/inspector/src/worker/realms/host/console.ts create mode 100644 packages/experimental/inspector/src/worker/realms/host/debugger.ts create mode 100644 packages/experimental/inspector/src/worker/realms/host/index.ts create mode 100644 packages/experimental/inspector/src/worker/realms/host/runtime.ts create mode 100644 packages/experimental/inspector/src/worker/realms/host/scripts.ts create mode 100644 packages/experimental/inspector/src/worker/realms/host/sources.ts create mode 100644 packages/experimental/inspector/src/worker/realms/host/values.ts create mode 100644 packages/experimental/inspector/src/worker/server.ts create mode 100644 packages/experimental/inspector/tests/built-lib.e2e.ts create mode 100644 packages/experimental/inspector/tests/client-browser.e2e.ts create mode 100644 packages/experimental/inspector/tests/client-stack.host.spec.ts create mode 100644 packages/experimental/inspector/tests/debugger.e2e.ts create mode 100644 packages/experimental/inspector/tests/fixtures/client-source.client.ts create mode 100644 packages/experimental/inspector/tests/fixtures/client-source.host.ts create mode 100644 packages/experimental/inspector/tests/fixtures/debug-host.ts create mode 100644 packages/experimental/inspector/tests/integration.host.spec.ts diff --git a/packages/experimental/inspector/src/worker/bridge/endpoint.ts b/packages/experimental/inspector/src/worker/bridge/endpoint.ts new file mode 100644 index 0000000000..eaec14aa11 --- /dev/null +++ b/packages/experimental/inspector/src/worker/bridge/endpoint.ts @@ -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() + private readonly ingestConnections = new Map() + + 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 { + 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 { + 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((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 { + 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 { + return new Promise((resolve) => { + server.close(() => { resolve() }) + }) +} diff --git a/packages/experimental/inspector/src/worker/bridge/hub.ts b/packages/experimental/inspector/src/worker/bridge/hub.ts new file mode 100644 index 0000000000..dfdffc2eee --- /dev/null +++ b/packages/experimental/inspector/src/worker/bridge/hub.ts @@ -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 + 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 + readonly connection: SourceConnection + expectedSequence: number + dropped: number + readonly topicCounts: Map +} + +/** 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> +} + +/** Serial Worker-side owner of every Host and Client source generation. */ +export class InspectorSourceRegistry { + private readonly sources = new Map() + 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. + } + } + } +} diff --git a/packages/experimental/inspector/src/worker/bridge/runtime-rpc.ts b/packages/experimental/inspector/src/worker/bridge/runtime-rpc.ts new file mode 100644 index 0000000000..e4e66c5031 --- /dev/null +++ b/packages/experimental/inspector/src/worker/bridge/runtime-rpc.ts @@ -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 +} + +interface ConsoleSubscription { + readonly target: ClientRuntimeTarget + readonly sessionId: ClientRuntimeSessionId + readonly listener: (event: RuntimeConsoleBackendEvent) => 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() + private readonly pending = new Map() + private readonly consoleSubscriptions = new Set() + 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) => 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 { + 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)}`) +} diff --git a/packages/experimental/inspector/src/worker/bridge/session.ts b/packages/experimental/inspector/src/worker/bridge/session.ts new file mode 100644 index 0000000000..677ad466ac --- /dev/null +++ b/packages/experimental/inspector/src/worker/bridge/session.ts @@ -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. + } +} diff --git a/packages/experimental/inspector/src/worker/bridge/source-rpc.ts b/packages/experimental/inspector/src/worker/bridge/source-rpc.ts new file mode 100644 index 0000000000..75f506c69a --- /dev/null +++ b/packages/experimental/inspector/src/worker/bridge/source-rpc.ts @@ -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 +} + +/** 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() + 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 { + 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)}`) +} diff --git a/packages/experimental/inspector/src/worker/cdp/domains/debugger/cdp-params.ts b/packages/experimental/inspector/src/worker/cdp/domains/debugger/cdp-params.ts new file mode 100644 index 0000000000..8d38c915b2 --- /dev/null +++ b/packages/experimental/inspector/src/worker/cdp/domains/debugger/cdp-params.ts @@ -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>, +): 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>): 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>).scriptId + if (typeof scriptId === 'string') return scriptId + } + return undefined +} diff --git a/packages/experimental/inspector/src/worker/cdp/domains/debugger/index.ts b/packages/experimental/inspector/src/worker/cdp/domains/debugger/index.ts new file mode 100644 index 0000000000..d95fb34a25 --- /dev/null +++ b/packages/experimental/inspector/src/worker/cdp/domains/debugger/index.ts @@ -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' diff --git a/packages/experimental/inspector/src/worker/cdp/domains/debugger/projector.ts b/packages/experimental/inspector/src/worker/cdp/domains/debugger/projector.ts new file mode 100644 index 0000000000..23f3a4eced --- /dev/null +++ b/packages/experimental/inspector/src/worker/cdp/domains/debugger/projector.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, + 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> { + return { + scriptId: cdpScriptId(value.scriptKey), + lineNumber: value.lineNumber, + ...(value.columnNumber === undefined ? {} : { columnNumber: value.columnNumber }), + } +} + +function stackTrace(value: RuntimeStackTrace): Readonly> { + 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)}`) +} diff --git a/packages/experimental/inspector/src/worker/cdp/domains/debugger/script-registry.ts b/packages/experimental/inspector/src/worker/cdp/domains/debugger/script-registry.ts new file mode 100644 index 0000000000..9639b1bbfa --- /dev/null +++ b/packages/experimental/inspector/src/worker/cdp/domains/debugger/script-registry.ts @@ -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() + private readonly retiredUnsupported = new Set() + + /** + * 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') +} diff --git a/packages/experimental/inspector/src/worker/cdp/domains/debugger/session.ts b/packages/experimental/inspector/src/worker/cdp/domains/debugger/session.ts new file mode 100644 index 0000000000..c27ff59325 --- /dev/null +++ b/packages/experimental/inspector/src/worker/cdp/domains/debugger/session.ts @@ -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 void>() + private readonly debuggerDisposers = new Map void>() + private readonly callFrameRealms = new Map() + 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>): Promise>> { + 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>> { + 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>): Promise { + 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>): Promise { + 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>): Promise { + 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 { + 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>): Promise { + 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> + 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>): 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 { + 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 { + 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, + ): 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): 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>[]): Readonly> { + const merged: Record = {} + 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 +} diff --git a/packages/experimental/inspector/src/worker/cdp/domains/native.ts b/packages/experimental/inspector/src/worker/cdp/domains/native.ts new file mode 100644 index 0000000000..dda9bdd8d5 --- /dev/null +++ b/packages/experimental/inspector/src/worker/cdp/domains/native.ts @@ -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']) diff --git a/packages/experimental/inspector/src/worker/cdp/domains/runtime/cdp-params.ts b/packages/experimental/inspector/src/worker/cdp/domains/runtime/cdp-params.ts new file mode 100644 index 0000000000..b38c40b334 --- /dev/null +++ b/packages/experimental/inspector/src/worker/cdp/domains/runtime/cdp-params.ts @@ -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, '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>): 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>, +): { + readonly objectId: string + readonly request: Omit, '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>): 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>): { + readonly promiseObjectId: string + readonly request: Omit, '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>): 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>): 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>): 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>, + 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( + value: Readonly>, + key: Key, +): Partial>>> { + 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>>> +} diff --git a/packages/experimental/inspector/src/worker/cdp/domains/runtime/index.ts b/packages/experimental/inspector/src/worker/cdp/domains/runtime/index.ts new file mode 100644 index 0000000000..f6a1d888e9 --- /dev/null +++ b/packages/experimental/inspector/src/worker/cdp/domains/runtime/index.ts @@ -0,0 +1,3 @@ +/** Client-aware Runtime domain exports. */ + +export { RuntimeDomainSession } from './session.ts' diff --git a/packages/experimental/inspector/src/worker/cdp/domains/runtime/object-table.ts b/packages/experimental/inspector/src/worker/cdp/domains/runtime/object-table.ts new file mode 100644 index 0000000000..d620af28f2 --- /dev/null +++ b/packages/experimental/inspector/src/worker/cdp/domains/runtime/object-table.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> + readonly exceptionDetails?: Readonly> +} + +/** CDP Runtime payload derived from one realm's property descriptors. */ +export interface CdpGetPropertiesResult { + readonly result: readonly Readonly>[] + readonly internalProperties?: readonly Readonly>[] + readonly privateProperties?: readonly Readonly>[] + readonly exceptionDetails?: Readonly> +} + +/** One CDP notification projected from a realm Console event. */ +export interface CdpRuntimeEvent { + readonly method: 'Runtime.consoleAPICalled' | 'Runtime.exceptionThrown' + readonly params: Readonly> +} + +/** Maps every realm's backend handles to object ids scoped to one CDP connection. */ +export class RuntimeObjectTable { + private readonly routes = new Map() + 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, + 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, + 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, + ): 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() + 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, + group: string | undefined, + ): Readonly> { + 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, + group: string | undefined, + ): Readonly> { + 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, + group: string | undefined, + ): Readonly> { + return { + name: property.name, + ...(property.value === undefined ? {} : { value: this.remote(realm, property.value, group) }), + } + } + + private privateProperty( + realm: InspectorRealmSession, + property: RuntimePrivatePropertyDescriptor, + group: string | undefined, + ): Readonly> { + 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, + group: string | undefined, + ): Readonly> { + 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> { + 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) }), + } +} diff --git a/packages/experimental/inspector/src/worker/cdp/domains/runtime/session.ts b/packages/experimental/inspector/src/worker/cdp/domains/runtime/session.ts new file mode 100644 index 0000000000..9b259ab7fb --- /dev/null +++ b/packages/experimental/inspector/src/worker/cdp/domains/runtime/session.ts @@ -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() + private readonly consoleDisposers = new Map 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, + 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, + group: string | undefined, + ): Readonly> { + 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>): Readonly> { + 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> + } + + /** + * 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>> { + 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 { + 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 { + 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>): Promise { + 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>): Promise { + 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>): Promise { + const parsed = parseGlobalLexicalScopeNames(params) + const realm = this.realmFromSelector(parsed, 'executionContextId') + return { names: await runtimeBackend(realm).globalLexicalScopeNames() } + } + + private async discardConsoleEntries(): Promise { + 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 { + 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>): 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): 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 +} diff --git a/packages/experimental/inspector/src/worker/cdp/ids.ts b/packages/experimental/inspector/src/worker/cdp/ids.ts new file mode 100644 index 0000000000..ae72f936f6 --- /dev/null +++ b/packages/experimental/inspector/src/worker/cdp/ids.ts @@ -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 = 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(value: string, label: string): InspectorId { + if (value.length === 0 || value.length > 16_384) { + throw new Error(`inspector CDP: ${label} must contain 1 to 16384 characters`) + } + return value as InspectorId +} + +/** + * 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(value: number, label: string): CdpNumericId { + if (!Number.isSafeInteger(value) || value < 1) throw new Error(`inspector CDP: ${label} must be a positive integer`) + return value as CdpNumericId +} diff --git a/packages/experimental/inspector/src/worker/cdp/protocol.ts b/packages/experimental/inspector/src/worker/cdp/protocol.ts new file mode 100644 index 0000000000..be2a9d6640 --- /dev/null +++ b/packages/experimental/inspector/src/worker/cdp/protocol.ts @@ -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> +} + +/** Outbound CDP event. */ +export interface CdpNotification { + readonly method: string + readonly params: Readonly> +} + +/** 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, +): void { + void operation().then( + (result) => { transport.send({ id: request.id, result }) }, + (error: unknown) => { sendCdpFailure(transport, request, error) }, + ) +} diff --git a/packages/experimental/inspector/src/worker/cdp/realm-sessions.ts b/packages/experimental/inspector/src/worker/cdp/realm-sessions.ts new file mode 100644 index 0000000000..32f7baeed9 --- /dev/null +++ b/packages/experimental/inspector/src/worker/cdp/realm-sessions.ts @@ -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() + 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. + } + } + } +} diff --git a/packages/experimental/inspector/src/worker/cdp/session.ts b/packages/experimental/inspector/src/worker/cdp/session.ts new file mode 100644 index 0000000000..8ca7c1f686 --- /dev/null +++ b/packages/experimental/inspector/src/worker/cdp/session.ts @@ -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>): 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() + } +} diff --git a/packages/experimental/inspector/src/worker/cdp/target.ts b/packages/experimental/inspector/src/worker/cdp/target.ts new file mode 100644 index 0000000000..bdfcd4e50d --- /dev/null +++ b/packages/experimental/inspector/src/worker/cdp/target.ts @@ -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 + } +} diff --git a/packages/experimental/inspector/src/worker/entry.ts b/packages/experimental/inspector/src/worker/entry.ts new file mode 100644 index 0000000000..c1f5ff9495 --- /dev/null +++ b/packages/experimental/inspector/src/worker/entry.ts @@ -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 = { + hostSourcePort: bootData.hostSourcePort, + config: parseInspectorWorkerConfig(bootData.config), +} + +let runtime: Awaited> | undefined +let stopping: Promise | undefined + +const stop = (): Promise => { + 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() +} diff --git a/packages/experimental/inspector/src/worker/inspection/realm-store.ts b/packages/experimental/inspector/src/worker/inspection/realm-store.ts new file mode 100644 index 0000000000..9228a39253 --- /dev/null +++ b/packages/experimental/inspector/src/worker/inspection/realm-store.ts @@ -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() + 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. + } + } + } +} diff --git a/packages/experimental/inspector/src/worker/inspection/realm.ts b/packages/experimental/inspector/src/worker/inspection/realm.ts new file mode 100644 index 0000000000..740d5bf44e --- /dev/null +++ b/packages/experimental/inspector/src/worker/inspection/realm.ts @@ -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 + readonly console: RealmCapability + readonly sources: RealmCapability + readonly debugger: RealmCapability + readonly nativeDomains: RealmCapability + /** 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 +} diff --git a/packages/experimental/inspector/src/worker/realms/client/bridge.ts b/packages/experimental/inspector/src/worker/realms/client/bridge.ts new file mode 100644 index 0000000000..3ea3da35d7 --- /dev/null +++ b/packages/experimental/inspector/src/worker/realms/client/bridge.ts @@ -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 } +} diff --git a/packages/experimental/inspector/src/worker/realms/client/console.ts b/packages/experimental/inspector/src/worker/realms/client/console.ts new file mode 100644 index 0000000000..0d74fadb8f --- /dev/null +++ b/packages/experimental/inspector/src/worker/realms/client/console.ts @@ -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) => 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 {} + + /** Disable every active Console subscription for this connection. */ + close(): void { + for (const dispose of this.disposers) dispose() + this.disposers.clear() + } +} diff --git a/packages/experimental/inspector/src/worker/realms/client/debugger.ts b/packages/experimental/inspector/src/worker/realms/client/debugger.ts new file mode 100644 index 0000000000..9baaf8a7ac --- /dev/null +++ b/packages/experimental/inspector/src/worker/realms/client/debugger.ts @@ -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 { + return { state: 'unsupported', reason: 'Client native debugging is unavailable' } +} diff --git a/packages/experimental/inspector/src/worker/realms/client/index.ts b/packages/experimental/inspector/src/worker/realms/client/index.ts new file mode 100644 index 0000000000..5952f9c4db --- /dev/null +++ b/packages/experimental/inspector/src/worker/realms/client/index.ts @@ -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) +} diff --git a/packages/experimental/inspector/src/worker/realms/client/runtime.ts b/packages/experimental/inspector/src/worker/realms/client/runtime.ts new file mode 100644 index 0000000000..ef44975849 --- /dev/null +++ b/packages/experimental/inspector/src/worker/realms/client/runtime.ts @@ -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 { + return Promise.resolve() + } + + disable(): Promise { + this.router.closeTargetSession(this.target, this.sessionId) + return Promise.resolve() + } + + async evaluate(request: Parameters[0]): ReturnType { + 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[0]): ReturnType { + 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[0]): ReturnType { + assertClientCallOptions(request) + const { + receiver, + arguments: args, + throwOnSideEffect: _throwOnSideEffect, + serializationOptions: _serializationOptions, + ...options + } = request + const command: Extract = { + 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[0]): ReturnType { + return clientCompletion( + expectResult(await this.request({ + op: 'await-promise', + ...request, + promise: clientHandle(request.promise), + }), 'await-promise'), + scriptKey => this.scriptIds.toRuntime(scriptKey), + ) + } + + async globalLexicalScopeNames(): Promise { + return expectResult(await this.request({ op: 'global-lexical-scope-names' }), 'global-lexical-scope-names').names + } + + async releaseObject(handle: RuntimeBackendObjectHandle): Promise { + expectResult(await this.request({ op: 'release-object', handle: clientHandle(handle) }), 'release-object') + } + + async releaseObjectGroup(group: string): Promise { + 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 { + 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): ClientCallArgument { + return value.kind === 'object' ? { kind: 'object', handle: clientHandle(value.handle) } : value +} + +function expectResult( + result: ClientRuntimeResult, + operation: Operation, +): Extract { + if (result.op !== operation) throw new Error(`Client Runtime returned ${result.op} for ${operation}`) + return result as Extract +} + +function assertClientEvaluationOptions(request: Parameters[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[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') +} diff --git a/packages/experimental/inspector/src/worker/realms/client/scripts.ts b/packages/experimental/inspector/src/worker/realms/client/scripts.ts new file mode 100644 index 0000000000..5e0df8adb9 --- /dev/null +++ b/packages/experimental/inspector/src/worker/realms/client/scripts.ts @@ -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() + + 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 + } +} diff --git a/packages/experimental/inspector/src/worker/realms/client/sources.ts b/packages/experimental/inspector/src/worker/realms/client/sources.ts new file mode 100644 index 0000000000..1d89c52eeb --- /dev/null +++ b/packages/experimental/inspector/src/worker/realms/client/sources.ts @@ -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() + private catalog: Promise | undefined + private closed = false + + constructor( + private readonly target: ClientRuntimeTarget, + private readonly sessionId: ClientSourceSessionId, + private readonly router: ClientSourceRouter, + private readonly scriptIds: ClientScriptIdentity, + ) {} + + async listScripts(): Promise { + if (this.closed) throw new Error('Client source session is closed') + this.catalog ??= this.loadCatalog() + return this.catalog + } + + async getScriptSource(scriptKey: RuntimeScriptKey): Promise { + 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 { + 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 { + 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 { + 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 { + 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( + result: ClientSourceResult, + operation: Operation, +): Extract { + if (result.op !== operation) throw new Error(`Client source returned ${result.op} for ${operation}`) + return result as Extract +} diff --git a/packages/experimental/inspector/src/worker/realms/client/values.ts b/packages/experimental/inspector/src/worker/realms/client/values.ts new file mode 100644 index 0000000000..f39b52127c --- /dev/null +++ b/packages/experimental/inspector/src/worker/realms/client/values.ts @@ -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, + mapScriptKey: ClientScriptKeyMapper, +): RuntimeCompletion { + 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 { + 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, +): RuntimeInternalPropertyDescriptor { + 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 { + 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, + mapScriptKey: ClientScriptKeyMapper, +): RuntimeConsoleBackendEvent { + 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 { + 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) }), + } +} diff --git a/packages/experimental/inspector/src/worker/realms/host/bridge.ts b/packages/experimental/inspector/src/worker/realms/host/bridge.ts new file mode 100644 index 0000000000..4a9955c1e0 --- /dev/null +++ b/packages/experimental/inspector/src/worker/realms/host/bridge.ts @@ -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> | undefined, + callback: (error: Error | null, result?: Readonly>) => 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>): Promise>> { + 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> + const auxData = record.auxData + if (typeof auxData !== 'object' || auxData === null || (auxData as Readonly>).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 { + 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, + ) { + 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) +} diff --git a/packages/experimental/inspector/src/worker/realms/host/console.ts b/packages/experimental/inspector/src/worker/realms/host/console.ts new file mode 100644 index 0000000000..0c6574ebd1 --- /dev/null +++ b/packages/experimental/inspector/src/worker/realms/host/console.ts @@ -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([ + '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> + + 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) => void): () => void { + return this.events.subscribe(listener) + } + + async clear(): Promise { + await this.target.request('Runtime.discardConsoleEntries', {}) + } + + /** Release the native notification subscription. */ + close(): void { + this.events.close() + } + + private async consoleEvent( + params: Readonly> | undefined, + ): Promise | 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> | undefined, + ): Promise | 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), + }, + } + } +} diff --git a/packages/experimental/inspector/src/worker/realms/host/debugger.ts b/packages/experimental/inspector/src/worker/realms/host/debugger.ts new file mode 100644 index 0000000000..95a9f83184 --- /dev/null +++ b/packages/experimental/inspector/src/worker/realms/host/debugger.ts @@ -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> + + 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[0]): Promise>> { + return this.target.request('Debugger.enable', { + ...optionalNativeField('maxScriptsCacheSize', request.maxScriptsCacheSize), + }) + } + + async disable(): Promise>> { + return this.target.request('Debugger.disable', {}) + } + + async pause(): Promise>> { + return this.target.request('Debugger.pause', {}) + } + + async resume(request: Parameters[0]): Promise>> { + return this.target.request('Debugger.resume', { + ...optionalNativeField('terminateOnResume', request.terminateOnResume), + }) + } + + async evaluateOnCallFrame( + request: Parameters[0], + ): ReturnType { + 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) => void): () => void { + return this.events.subscribe(listener) + } + + /** Release the native notification subscription. */ + close(): void { + this.events.close() + } + + private async paused( + params: Readonly> | undefined, + ): Promise | 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> { + 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> { + 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> | undefined, +): Extract, { 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') +} diff --git a/packages/experimental/inspector/src/worker/realms/host/index.ts b/packages/experimental/inspector/src/worker/realms/host/index.ts new file mode 100644 index 0000000000..7c8f7164d1 --- /dev/null +++ b/packages/experimental/inspector/src/worker/realms/host/index.ts @@ -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() + }, + } + } +} diff --git a/packages/experimental/inspector/src/worker/realms/host/runtime.ts b/packages/experimental/inspector/src/worker/realms/host/runtime.ts new file mode 100644 index 0000000000..bc1bd4d50f --- /dev/null +++ b/packages/experimental/inspector/src/worker/realms/host/runtime.ts @@ -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 { + await this.target.request('Runtime.enable', {}) + } + + async disable(): Promise { + await this.target.request('Runtime.disable', {}) + this.defaultContextId = undefined + } + + async evaluate(request: Parameters[0]): ReturnType { + 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[0]): ReturnType { + 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[0]): ReturnType { + 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[0]): ReturnType { + return this.completion(await this.target.request('Runtime.awaitPromise', { + promiseObjectId: request.promise, + ...optionalNativeField('returnByValue', request.returnByValue), + ...optionalNativeField('generatePreview', request.generatePreview), + })) + } + + async globalLexicalScopeNames(): Promise { + 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 { + await this.target.request('Runtime.releaseObject', { objectId: handle }) + } + + async releaseObjectGroup(group: string): Promise { + 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>): Promise> { + return { + result: await this.remoteObject(value.result), + ...(value.exceptionDetails === undefined + ? {} + : { exceptionDetails: await this.exceptionDetails(value.exceptionDetails) }), + } + } + + private async properties(value: Readonly>): Promise> { + 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> { + 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[]> { + 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[]> { + 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> { + 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> { + 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 { + 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): Readonly> { + 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)}`) +} diff --git a/packages/experimental/inspector/src/worker/realms/host/scripts.ts b/packages/experimental/inspector/src/worker/realms/host/scripts.ts new file mode 100644 index 0000000000..25e0549f9b --- /dev/null +++ b/packages/experimental/inspector/src/worker/realms/host/scripts.ts @@ -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') +} diff --git a/packages/experimental/inspector/src/worker/realms/host/sources.ts b/packages/experimental/inspector/src/worker/realms/host/sources.ts new file mode 100644 index 0000000000..d70305efe1 --- /dev/null +++ b/packages/experimental/inspector/src/worker/realms/host/sources.ts @@ -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() + 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 { + return Promise.resolve([...this.scripts.values()].map(script => script.descriptor)) + } + + async getScriptSource(scriptKey: RuntimeScriptKey): Promise { + 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 { + 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 +} diff --git a/packages/experimental/inspector/src/worker/realms/host/values.ts b/packages/experimental/inspector/src/worker/realms/host/values.ts new file mode 100644 index 0000000000..afd4e5b222 --- /dev/null +++ b/packages/experimental/inspector/src/worker/realms/host/values.ts @@ -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> { + 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> { + 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: Key, + value: Value | undefined, +): Partial> { + return value === undefined ? {} : { [key]: value } as Partial> +} diff --git a/packages/experimental/inspector/src/worker/server.ts b/packages/experimental/inspector/src/worker/server.ts new file mode 100644 index 0000000000..4bebfc3ad7 --- /dev/null +++ b/packages/experimental/inspector/src/worker/server.ts @@ -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 +} + +/** + * 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): Promise { + 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 | undefined + return { + endpoint, + close(): Promise { + 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 + }, + } +} diff --git a/packages/experimental/inspector/tests/built-lib.e2e.ts b/packages/experimental/inspector/tests/built-lib.e2e.ts new file mode 100644 index 0000000000..00229c79da --- /dev/null +++ b/packages/experimental/inspector/tests/built-lib.e2e.ts @@ -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' }) + }) +}) diff --git a/packages/experimental/inspector/tests/client-browser.e2e.ts b/packages/experimental/inspector/tests/client-browser.e2e.ts new file mode 100644 index 0000000000..e9d1b841fe --- /dev/null +++ b/packages/experimental/inspector/tests/client-browser.e2e.ts @@ -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 + readonly result?: Record + readonly error?: { message: string } +} + +class BrowserTestCdpClient { + private nextId = 0 + private readonly pending = new Map 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 { + const socket = new WebSocket(url) + await new Promise((resolve, reject) => { + socket.once('open', () => { resolve() }) + socket.once('error', reject) + }) + return new BrowserTestCdpClient(socket) + } + + call(method: string, params: Record = {}): Promise { + 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 { + 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 { + if (this.socket.readyState === WebSocket.CLOSED) return + const closed = new Promise((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((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((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 | undefined + return String(context?.name).startsWith('Client —') + }) + const contextId = (contextEvent.params?.context as Record).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 | undefined + return details !== undefined + && details.executionContextId === contextId + && String((details.exception as Record | undefined)?.description).includes('browser-client-exception') + }) + const exceptionDetails = exception.params?.exceptionDetails as Record + 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 ` +Inspector Browser Client + + +` +} + +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> { + if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('expected a record') + return value as Readonly> +} + +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') +} diff --git a/packages/experimental/inspector/tests/client-stack.host.spec.ts b/packages/experimental/inspector/tests/client-stack.host.spec.ts new file mode 100644 index 0000000000..d6080f326f --- /dev/null +++ b/packages/experimental/inspector/tests/client-stack.host.spec.ts @@ -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)) + }) +}) diff --git a/packages/experimental/inspector/tests/debugger.e2e.ts b/packages/experimental/inspector/tests/debugger.e2e.ts new file mode 100644 index 0000000000..a7cb27e2b6 --- /dev/null +++ b/packages/experimental/inspector/tests/debugger.e2e.ts @@ -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 + readonly result?: Record + readonly error?: { message: string } +} + +class CdpClient { + private nextId = 0 + private readonly pending = new Map 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 { + const socket = new WebSocket(url) + await new Promise((resolve, reject) => { + socket.once('open', () => { resolve() }) + socket.once('error', reject) + }) + return new CdpClient(socket) + } + + call(method: string, params: Record = {}): Promise { + 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 { + 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 { + if (this.socket.readyState === WebSocket.CLOSED) return + const closed = new Promise((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 | 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> + const callFrameId = callFrames[0]?.callFrameId + expect(typeof callFrameId).toBe('string') + const scopeChain = callFrames[0]?.scopeChain as Array> + const scopeObjectId = (scopeChain[0]?.object as Record | 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 | 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((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 { + 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) + }) +} diff --git a/packages/experimental/inspector/tests/fixtures/client-source.client.ts b/packages/experimental/inspector/tests/fixtures/client-source.client.ts new file mode 100644 index 0000000000..a6616facda --- /dev/null +++ b/packages/experimental/inspector/tests/fixtures/client-source.client.ts @@ -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 { + 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 +} diff --git a/packages/experimental/inspector/tests/fixtures/client-source.host.ts b/packages/experimental/inspector/tests/fixtures/client-source.host.ts new file mode 100644 index 0000000000..f63d71a881 --- /dev/null +++ b/packages/experimental/inspector/tests/fixtures/client-source.host.ts @@ -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>() + 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 { + const ready = Promise.withResolvers() + 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 { + await this.request({ op: 'publish', topic, value }) + } + + /** Set one JSON-compatible global used by Client Runtime evaluation. */ + async setGlobal(name: string, value: InspectorJsonValue): Promise { + await this.request({ op: 'set-global', name, value }) + } + + /** Emit one Console event carrying a caller-provided value. */ + async log(value: InspectorJsonValue, marker: string): Promise { + await this.request({ op: 'log-value', value, marker }) + } + + /** Emit one Console event carrying the fixture's Context and Fiber. */ + async logCordis(marker: string): Promise { + await this.request({ op: 'log-cordis', marker }) + } + + /** Read the consumer-neutral Cordis tree through the Client service. */ + async getCordisTree(): Promise { + return await this.request({ op: 'get-tree' }) as CordisRuntimeTree + } + + /** Break the active ingest socket while preserving the Client source. */ + async disconnect(): Promise { + await this.request({ op: 'disconnect' }) + } + + /** Dispose the Client source and its Cordis context. */ + async close(): Promise { + if (this.closed) return + await this.request({ op: 'close' }) + this.closed = true + await this.worker.terminate() + } + + private async request(fields: Record): Promise { + if (this.closed) throw new Error('Inspector Client fixture is closed') + const id = ++this.nextId + const result = Promise.withResolvers() + 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 { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/packages/experimental/inspector/tests/fixtures/debug-host.ts b/packages/experimental/inspector/tests/fixtures/debug-host.ts new file mode 100644 index 0000000000..880831920c --- /dev/null +++ b/packages/experimental/inspector/tests/fixtures/debug-host.ts @@ -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) }) + } +}) diff --git a/packages/experimental/inspector/tests/integration.host.spec.ts b/packages/experimental/inspector/tests/integration.host.spec.ts new file mode 100644 index 0000000000..b5c7bd914e --- /dev/null +++ b/packages/experimental/inspector/tests/integration.host.spec.ts @@ -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 + readonly result?: Record + readonly error?: { message: string } +} + +class TestCdpClient { + private nextId = 0 + private readonly pending = new Map 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 { + const socket = new WebSocket(url) + await new Promise((resolve, reject) => { + socket.once('open', () => { resolve() }) + socket.once('error', reject) + }) + return new TestCdpClient(socket) + } + + call(method: string, params: Record = {}): Promise { + 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 { + if (this.socket.readyState === WebSocket.CLOSED) return + const closed = new Promise((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((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 }> + 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).__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 | 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 + 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((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((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 | 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 + 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() + 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((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 | 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 { + 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>[] { + 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>[] { + if (!Array.isArray(value)) throw new Error('expected an array of records') + return value.map(asRecord) +} + +function asRecord(value: unknown): Readonly> { + if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('expected a record') + return value as Readonly> +} + +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') +}