diff --git a/packages/experimental/inspector/src/client/bridge/controller.ts b/packages/experimental/inspector/src/client/bridge/controller.ts new file mode 100644 index 0000000000..67e408cb76 --- /dev/null +++ b/packages/experimental/inspector/src/client/bridge/controller.ts @@ -0,0 +1,13 @@ +/** Browser Client bridge construction for the Cordis plugin entry. */ + +import type { InspectorClientBootstrap } from '../../shared/bridge/messages/control.ts' +import { ClientInspectorSource } from './transport.ts' + +/** + * Start the browser source transport for one validated Host bootstrap. + * @param bootstrap - Host-injected endpoint and resource limits. + * @returns The active reconnecting Client source. + */ +export function startInspectorClient(bootstrap: InspectorClientBootstrap): ClientInspectorSource { + return new ClientInspectorSource(bootstrap) +} diff --git a/packages/experimental/inspector/src/client/bridge/dispatcher.ts b/packages/experimental/inspector/src/client/bridge/dispatcher.ts new file mode 100644 index 0000000000..535a8ba237 --- /dev/null +++ b/packages/experimental/inspector/src/client/bridge/dispatcher.ts @@ -0,0 +1,66 @@ +/** Dispatch of validated Worker frames to browser-realm capability handlers. */ + +import type { + ClientConsoleDisableFrame, + ClientConsoleEnableFrame, + ClientRuntimeRequestFrame, + ClientRuntimeSessionClosedFrame, +} from '../../shared/bridge/messages/runtime/index.ts' +import type { ClientSourceRequestFrame, ClientSourceSessionClosedFrame } from '../../shared/bridge/messages/sources/index.ts' +import type { SourceAcceptedFrame, SourceRejectedFrame, SourceResnapshotFrame, WorkerToSourceFrame } from '../../shared/bridge/messages/observation.ts' + +/** Operations invoked for each Worker-to-Client frame family. */ +export interface ClientBridgeFrameHandlers { + accepted(frame: SourceAcceptedFrame): void + resnapshot(frame: SourceResnapshotFrame): void + rejected(frame: SourceRejectedFrame): void + runtime(frame: ClientRuntimeRequestFrame): void + runtimeClosed(frame: ClientRuntimeSessionClosedFrame): void + consoleEnabled(frame: ClientConsoleEnableFrame): void + consoleDisabled(frame: ClientConsoleDisableFrame): void + sources(frame: ClientSourceRequestFrame): void + sourcesClosed(frame: ClientSourceSessionClosedFrame): void +} + +/** + * Dispatch one validated Worker frame without exposing transport details to domain adapters. + * @param frame - Decoded Worker-to-source frame. + * @param handlers - Browser-realm operations for each frame family. + */ +export function dispatchBridgeFrame(frame: WorkerToSourceFrame, handlers: ClientBridgeFrameHandlers): void { + switch (frame.t) { + case 'source/accepted': + handlers.accepted(frame) + return + case 'source/resnapshot': + handlers.resnapshot(frame) + return + case 'source/rejected': + handlers.rejected(frame) + return + case 'client-runtime/request': + handlers.runtime(frame) + return + case 'client-runtime/session-closed': + handlers.runtimeClosed(frame) + return + case 'client-console/enable': + handlers.consoleEnabled(frame) + return + case 'client-console/disable': + handlers.consoleDisabled(frame) + return + case 'client-sources/request': + handlers.sources(frame) + return + case 'client-sources/session-closed': + handlers.sourcesClosed(frame) + return + default: + return assertNever(frame) + } +} + +function assertNever(value: never): never { + throw new Error(`Unexpected Worker source frame: ${JSON.stringify(value)}`) +} diff --git a/packages/experimental/inspector/src/client/bridge/lifecycle.ts b/packages/experimental/inspector/src/client/bridge/lifecycle.ts new file mode 100644 index 0000000000..62e0bc63f9 --- /dev/null +++ b/packages/experimental/inspector/src/client/bridge/lifecycle.ts @@ -0,0 +1,40 @@ +/** Reconnection lifecycle for the browser Client bridge. */ + +/** Owns one bounded-backoff timer and prevents reconnection after disposal. */ +export class ClientBridgeLifecycle { + private reconnectAttempt = 0 + private reconnectTimer: ReturnType | undefined + private closed = false + + constructor( + private readonly baseDelayMs: number, + private readonly maxDelayMs: number, + ) {} + + /** Reset backoff after the Worker accepts a source generation. */ + connected(): void { + this.reconnectAttempt = 0 + } + + /** + * Schedule the next reconnect attempt unless one is already pending. + * @param connect - Operation that opens the next transport generation. + */ + reconnect(connect: () => void): void { + if (this.reconnectTimer !== undefined || this.closed) return + const cap = Math.min(this.maxDelayMs, this.baseDelayMs * 2 ** this.reconnectAttempt) + this.reconnectAttempt++ + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = undefined + connect() + }, cap / 2 + Math.random() * cap / 2) + } + + /** Stop pending and future reconnect attempts. */ + close(): void { + if (this.closed) return + this.closed = true + if (this.reconnectTimer !== undefined) clearTimeout(this.reconnectTimer) + this.reconnectTimer = undefined + } +} diff --git a/packages/experimental/inspector/src/client/bridge/publisher.ts b/packages/experimental/inspector/src/client/bridge/publisher.ts new file mode 100644 index 0000000000..61a817a5fe --- /dev/null +++ b/packages/experimental/inspector/src/client/bridge/publisher.ts @@ -0,0 +1,110 @@ +/** Buffered Client observation publication across reconnecting WebSockets. */ + +import { InspectorSourceBuffer, type InspectorSourceBufferOptions } from '../../shared/bridge/buffer.ts' +import type { InspectorJsonValue } from '../../shared/json.ts' +import type { InspectorStatePublisher } from '../../shared/bridge/publisher.ts' +import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts' + +interface ActivePublication { + readonly socket: WebSocket + readonly source: InspectorSourceDescriptor + accepted: boolean +} + +/** Non-blocking Client publisher whose bounded state survives transport reconnects. */ +export class ClientBridgePublisher implements InspectorStatePublisher { + private readonly records: InspectorSourceBuffer + private active: ActivePublication | undefined + private flushTimer: ReturnType | undefined + private closed = false + + constructor( + options: InspectorSourceBufferOptions, + private readonly maxBufferedBytes: number, + ) { + this.records = new InspectorSourceBuffer(options) + } + + publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()): void { + if (this.closed) return + this.records.publish(topic, payload, monotonicMs) + this.flush() + } + + setState(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()): void { + if (this.closed) throw new Error('inspector: Client source is closed') + this.records.setState(topic, payload, monotonicMs) + this.flush() + } + + /** + * Install one unopened transport generation. + * @param socket - WebSocket carrying the generation. + * @param source - Source identity and generation sent by the socket. + */ + connect(socket: WebSocket, source: InspectorSourceDescriptor): void { + this.active = { socket, source, accepted: false } + } + + /** + * Send retained state and queued observations after Worker acceptance. + * @param socket - Accepted active WebSocket. + */ + accept(socket: WebSocket): void { + const active = this.active + if (active?.socket !== socket) return + active.accepted = true + this.replace(socket) + this.flush() + } + + /** + * Resend retained state for the active generation. + * @param socket - WebSocket that received the resnapshot request. + */ + replace(socket: WebSocket): void { + const active = this.active + if (active?.socket !== socket || socket.readyState !== WebSocket.OPEN) return + socket.send(JSON.stringify(this.records.replacement(active.source.sourceId, active.source.generation))) + } + + /** + * Forget one closed transport while retaining buffered state for reconnect. + * @param socket - WebSocket whose close event fired. + */ + disconnect(socket: WebSocket): void { + if (this.active?.socket === socket) this.active = undefined + } + + /** Stop delayed writes and reject later publication. */ + close(): void { + if (this.closed) return + this.closed = true + this.active = undefined + if (this.flushTimer !== undefined) clearTimeout(this.flushTimer) + this.flushTimer = undefined + } + + private flush(): void { + const active = this.active + if (!active?.accepted || active.socket.readyState !== WebSocket.OPEN) return + if (active.socket.bufferedAmount > this.maxBufferedBytes) { + this.scheduleFlush() + return + } + while (this.records.hasPending && active.socket.bufferedAmount <= this.maxBufferedBytes) { + const frame = this.records.takeBatch(active.source.sourceId, active.source.generation) + if (frame === undefined) break + active.socket.send(JSON.stringify(frame)) + } + if (this.records.hasPending) this.scheduleFlush() + } + + private scheduleFlush(): void { + if (this.flushTimer !== undefined || this.closed) return + this.flushTimer = setTimeout(() => { + this.flushTimer = undefined + this.flush() + }, 25) + } +} diff --git a/packages/experimental/inspector/src/client/bridge/rpc.ts b/packages/experimental/inspector/src/client/bridge/rpc.ts new file mode 100644 index 0000000000..19b0c8d7dc --- /dev/null +++ b/packages/experimental/inspector/src/client/bridge/rpc.ts @@ -0,0 +1,62 @@ +/** Client-side non-CDP query bridge over the active Worker WebSocket. */ + +import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts' +import type { InspectorQuery, InspectorQueryResultFor } from '../../shared/bridge/messages/query/commands.ts' +import { InspectorQueryConnection, type InspectorQueryConnectionOptions } from '../../shared/bridge/rpc.ts' + +/** Owns query correlation across reconnecting Client source generations. */ +export class ClientBridgeRpc { + private readonly connection: InspectorQueryConnection + + constructor(options: InspectorQueryConnectionOptions) { + this.connection = new InspectorQueryConnection(options) + } + + /** + * Connect query writes to one accepted Client WebSocket generation. + * @param source - Accepted source descriptor. + * @param socket - Active source WebSocket. + */ + connect(source: InspectorSourceDescriptor, socket: WebSocket): void { + this.connection.connect(source.sourceId, source.generation, { + send: (frame) => { + if (socket.readyState !== WebSocket.OPEN) throw new Error('Inspector Client query socket is not connected') + socket.send(JSON.stringify(frame)) + }, + }) + } + + /** + * Consume a potential query response. + * @param value - Decoded Worker message. + * @returns Whether the message belonged to this RPC protocol. + */ + receive(value: unknown): boolean { + return this.connection.receive(value) + } + + /** + * Execute one non-CDP query through the active Client generation. + * @param query - Typed query operation. + * @returns Its correlated typed result. + */ + request(query: Query): Promise> { + return this.connection.request(query) + } + + /** + * Reject pending requests while permitting a later Client generation. + * @param reason - Failure reported to pending callers. + */ + disconnect(reason: string): void { + this.connection.disconnect(reason) + } + + /** + * Permanently reject all current and future requests. + * @param reason - Failure reported to pending callers. + */ + close(reason: string): void { + this.connection.close(reason) + } +} diff --git a/packages/experimental/inspector/src/client/bridge/transport.ts b/packages/experimental/inspector/src/client/bridge/transport.ts new file mode 100644 index 0000000000..33ad831535 --- /dev/null +++ b/packages/experimental/inspector/src/client/bridge/transport.ts @@ -0,0 +1,274 @@ +/** Client observation and Runtime endpoint over the Inspector Worker's ingest WebSocket. */ + +import type { InspectorClientBootstrap } from '../../shared/bridge/messages/control.ts' +import type { InspectorSourceGeneration } from '../../shared/bridge/ids.ts' +import { isJsonValue, jsonByteLength, type InspectorJsonValue } from '../../shared/json.ts' +import type { InspectorQuery, InspectorQueryResultFor } from '../../shared/bridge/messages/query/commands.ts' +import { + INSPECTOR_PROTOCOL_VERSION, + parseWorkerSourceFrame, + type SourceCloseFrame, + type SourceOpenFrame, +} from '../../shared/bridge/messages/observation.ts' +import type { InspectorConnection } from '../../shared/bridge/publisher.ts' +import { ClientConsoleObserver } from '../cdp/console.ts' +import { ClientRuntimeExecutor } from '../cdp/runtime.ts' +import { + ClientSourceCatalog, + ClientSourceCatalogError, + discoverInspectorClientSourceCatalog, +} from '../cdp/sources.ts' +import type { ClientSourceRequestFrame, ClientSourceResponseFrame } from '../../shared/bridge/messages/sources/index.ts' +import { ClientRealmSource } from '../inspection/realm.ts' +import { NETWORK_TOPICS } from '../inspection/network.ts' +import { ClientBridgeLifecycle } from './lifecycle.ts' +import { ClientBridgePublisher } from './publisher.ts' +import { ClientBridgeRpc } from './rpc.ts' +import { dispatchBridgeFrame } from './dispatcher.ts' + +/** Reconnecting Client source whose bounded queue never blocks page work. */ +export class ClientInspectorSource implements InspectorConnection { + private readonly realmSource: ClientRealmSource + private readonly publisher: ClientBridgePublisher + private socket: WebSocket | undefined + private generation: InspectorSourceGeneration | undefined + private accepted = false + private closed = false + private readonly runtime: ClientRuntimeExecutor + private readonly console: ClientConsoleObserver + private readonly queries: ClientBridgeRpc + private readonly lifecycle: ClientBridgeLifecycle + + constructor( + private readonly bootstrap: InspectorClientBootstrap, + label = document.title || 'Client', + private readonly sourceCatalog: ClientSourceCatalog | undefined = discoverInspectorClientSourceCatalog(), + ) { + this.realmSource = new ClientRealmSource(label) + this.lifecycle = new ClientBridgeLifecycle(bootstrap.reconnectBaseMs, bootstrap.reconnectMaxMs) + this.publisher = new ClientBridgePublisher({ + topics: ['*'], + maxQueuedRecords: bootstrap.maxQueuedRecords, + maxQueuedBytes: bootstrap.maxQueuedBytes, + maxRecordsPerFrame: bootstrap.maxRecordsPerFrame, + maxFrameBytes: bootstrap.maxFrameBytes, + }, bootstrap.maxQueuedBytes) + this.runtime = new ClientRuntimeExecutor({ + maxObjectsPerSession: bootstrap.maxRuntimeObjectsPerSession, + maxPropertiesPerResult: bootstrap.maxRuntimePropertiesPerResult, + maxResponseBytes: bootstrap.maxFrameBytes, + }, url => this.sourceCatalog?.scriptKeyForUrl(url)) + this.console = new ClientConsoleObserver(this.runtime, (sessionId, event) => { + const socket = this.socket + const generation = this.generation + if (this.closed + || !this.accepted + || socket?.readyState !== WebSocket.OPEN + || generation === undefined) return + const frame = { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'client-console/event', + sourceId: this.realmSource.sourceId, + generation, + sessionId, + event, + } as const + if (!isJsonValue(frame) || jsonByteLength(frame) > this.bootstrap.maxFrameBytes) return + try { + socket.send(JSON.stringify(frame)) + } catch { + // The socket close path resets this generation's Runtime and Console state. + } + }, url => this.sourceCatalog?.scriptKeyForUrl(url)) + this.queries = new ClientBridgeRpc({ + timeoutMs: bootstrap.queryTimeoutMs, + maxFrameBytes: bootstrap.maxFrameBytes, + }) + this.connect() + } + + /** Publish one JSON observation without waiting on the ingest socket. */ + publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()): void { + if (this.closed) return + this.publisher.publish(topic, payload, monotonicMs) + } + + /** Retain and publish one state value for reconnect and resnapshot recovery. */ + setState(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()): void { + if (this.closed) throw new Error('inspector: Client source is closed') + this.publisher.setState(topic, payload, monotonicMs) + } + + /** Execute one non-CDP query through the accepted Client source generation. */ + request(query: Query): Promise> { + return this.queries.request(query) + } + + /** Permanently stop reconnecting and close the active source generation. */ + close(): void { + if (this.closed) return + this.closed = true + this.console.close() + this.runtime.reset() + this.queries.close('Inspector Client source closed') + this.lifecycle.close() + this.publisher.close() + const socket = this.socket + const generation = this.generation + if (socket?.readyState === WebSocket.OPEN && generation !== undefined) { + const frame: SourceCloseFrame = { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'source/close', + sourceId: this.realmSource.sourceId, + generation, + } + socket.send(JSON.stringify(frame)) + socket.close(1000, 'Client source closed') + } else { + socket?.close() + } + this.socket = undefined + } + + private connect(): void { + if (this.closed) return + this.console.reset() + this.runtime.reset() + this.queries.disconnect('Inspector Client source reconnecting') + const source = this.realmSource.connect(this.sourceCatalog !== undefined) + const generation = source.generation + const socket = new WebSocket(this.bootstrap.endpoint, this.bootstrap.protocol) + this.socket = socket + this.generation = generation + this.accepted = false + this.publisher.connect(socket, source) + socket.addEventListener('open', () => { + if (this.socket !== socket || this.closed) return + const frame: SourceOpenFrame = { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'source/open', + source, + topics: ['*', ...NETWORK_TOPICS], + } + socket.send(JSON.stringify(frame)) + }) + socket.addEventListener('message', (event) => { + if (this.socket !== socket || typeof event.data !== 'string') return + try { + if (new TextEncoder().encode(event.data).byteLength > this.bootstrap.maxFrameBytes) { + throw new Error(`inspector protocol: Worker frame exceeds ${String(this.bootstrap.maxFrameBytes)} bytes`) + } + const value = JSON.parse(event.data) as unknown + if (this.queries.receive(value)) return + const frame = parseWorkerSourceFrame(value) + if (frame.t !== 'source/rejected' + && (frame.sourceId !== this.realmSource.sourceId || frame.generation !== generation)) return + dispatchBridgeFrame(frame, { + accepted: () => { + this.accepted = true + this.lifecycle.connected() + this.queries.connect(source, socket) + this.publisher.accept(socket) + }, + resnapshot: () => { this.publisher.replace(socket) }, + rejected: (rejected) => { + console.error(`[inspector] Client source rejected: ${rejected.message}`) + socket.close(1008, 'source rejected') + }, + runtime: (request) => { + void this.executeRuntime(socket, generation, request).catch((error: unknown) => { + console.error('[inspector] Client Runtime transport failed:', error) + socket.close(1011, 'Client Runtime transport failed') + }) + }, + runtimeClosed: (closed) => { + this.console.disable(closed.sessionId) + this.runtime.closeSession(closed.sessionId) + }, + consoleEnabled: (enabled) => { this.console.enable(enabled.sessionId) }, + consoleDisabled: (disabled) => { this.console.disable(disabled.sessionId) }, + sources: (request) => { + void this.executeSourceRequest(socket, generation, request).catch((error: unknown) => { + console.error('[inspector] Client Sources transport failed:', error) + socket.close(1011, 'Client Sources transport failed') + }) + }, + sourcesClosed: () => {}, + }) + } catch (error) { + console.error('[inspector] invalid Worker control frame:', error) + socket.close(1008, 'invalid Worker control frame') + } + }) + socket.addEventListener('close', () => { + if (this.socket !== socket || this.closed) return + this.socket = undefined + this.accepted = false + this.publisher.disconnect(socket) + this.console.reset() + this.runtime.reset() + this.queries.disconnect('Inspector Client source disconnected') + this.lifecycle.reconnect(() => { this.connect() }) + }) + socket.addEventListener('error', () => { + // `close` owns reconnection and keeps one timer. + }) + } + + private async executeRuntime( + socket: WebSocket, + generation: InspectorSourceGeneration, + frame: Extract, { t: 'client-runtime/request' }>, + ): Promise { + const response = await this.runtime.execute(frame) + if (this.closed || this.socket !== socket || this.generation !== generation || socket.readyState !== WebSocket.OPEN) return + socket.send(JSON.stringify(response)) + } + + private async executeSourceRequest( + socket: WebSocket, + generation: InspectorSourceGeneration, + frame: ClientSourceRequestFrame, + ): Promise { + let outcome: ClientSourceResponseFrame['outcome'] + try { + if (this.sourceCatalog === undefined) { + throw new ClientSourceCatalogError('invalid-request', 'Client source catalog is unavailable') + } + outcome = { ok: true, result: await this.sourceCatalog.execute(frame.command, this.bootstrap.maxClientSourceBytes) } + } catch (error) { + outcome = { + ok: false, + error: { + code: error instanceof ClientSourceCatalogError ? error.code : 'internal-error', + message: renderError(error).slice(0, 2_048), + }, + } + } + let response: ClientSourceResponseFrame = { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'client-sources/response', + sourceId: this.realmSource.sourceId, + generation, + sessionId: frame.sessionId, + requestId: frame.requestId, + outcome, + } + if (!isJsonValue(response) || jsonByteLength(response) > this.bootstrap.maxFrameBytes) { + response = { + ...response, + outcome: { + ok: false, + error: { code: 'result-too-large', message: 'Client source result exceeds the source-frame byte limit' }, + }, + } + } + if (this.closed || this.socket !== socket || this.generation !== generation || socket.readyState !== WebSocket.OPEN) return + socket.send(JSON.stringify(response)) + } + +} + +function renderError(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/packages/experimental/inspector/src/client/cdp/console.ts b/packages/experimental/inspector/src/client/cdp/console.ts new file mode 100644 index 0000000000..0bccf3d51c --- /dev/null +++ b/packages/experimental/inspector/src/client/cdp/console.ts @@ -0,0 +1,176 @@ +/** Client Console observation shared by every active DevTools Runtime session. */ + +import type { ClientRemoteObjectHandle, ClientRuntimeSessionId } from '../../shared/bridge/ids.ts' +import type { ClientConsoleCapability } from '../../shared/bridge/messages/runtime/index.ts' +import type { RuntimeConsoleBackendEvent, RuntimeConsoleType } from '../../shared/cdp/index.ts' +import type { ClientRuntimeExecutor } from './runtime.ts' +import { captureClientConsoleStack, clientErrorStack, type ClientScriptKeyResolver } from './stack.ts' + +/** + * Describe browser-side Console observation. + * @returns The Console capability advertised by a browser Client source. + */ +export function consoleBridgeCapability(): ClientConsoleCapability { + return { type: 'client-console' } +} + +/** Receives one Console event whose object handles belong to the given session. */ +export type ClientConsoleSink = ( + sessionId: ClientRuntimeSessionId, + event: RuntimeConsoleBackendEvent, +) => void + +const METHODS = [ + ['log', 'log'], + ['debug', 'debug'], + ['info', 'info'], + ['error', 'error'], + ['warn', 'warning'], + ['dir', 'dir'], + ['dirxml', 'dirxml'], + ['table', 'table'], + ['trace', 'trace'], + ['clear', 'clear'], + ['group', 'startGroup'], + ['groupCollapsed', 'startGroupCollapsed'], + ['groupEnd', 'endGroup'], + ['assert', 'assert'], + ['profile', 'profile'], + ['profileEnd', 'profileEnd'], + ['count', 'count'], + ['timeEnd', 'timeEnd'], +] as const satisfies readonly (readonly [string, RuntimeConsoleType])[] + +type ConsoleMethodName = typeof METHODS[number][0] + +interface InstalledMethod { + readonly name: ConsoleMethodName + readonly original: (...args: unknown[]) => unknown + readonly replacement: (...args: unknown[]) => unknown +} + +/** Installs one transparent console/error observer and fans out session-local values. */ +export class ClientConsoleObserver { + private readonly sessions = new Set() + private readonly installed: InstalledMethod[] = [] + private active = false + private closed = false + + constructor( + private readonly runtime: ClientRuntimeExecutor, + private readonly sink: ClientConsoleSink, + private readonly resolveScript: ClientScriptKeyResolver = () => undefined, + ) {} + + /** + * Start producing events for one DevTools Runtime session. + * @param sessionId - Session whose object table retains event arguments. + */ + enable(sessionId: ClientRuntimeSessionId): void { + if (this.closed) return + this.sessions.add(sessionId) + if (!this.active) this.install() + } + + /** + * Stop producing events and release Console objects for one session. + * @param sessionId - Session being disabled or closed. + */ + disable(sessionId: ClientRuntimeSessionId): void { + this.sessions.delete(sessionId) + this.runtime.releaseObjectGroup(sessionId, 'console') + if (this.sessions.size === 0) this.uninstall() + } + + /** Restore original browser hooks and clear every active session. */ + close(): void { + if (this.closed) return + this.closed = true + this.reset() + } + + /** Stop observing the current source generation while allowing a later reconnect. */ + reset(): void { + this.sessions.clear() + this.uninstall() + } + + private install(): void { + this.active = true + for (const [name, type] of METHODS) { + const candidate: unknown = Reflect.get(console, name) + if (typeof candidate !== 'function') continue + const original = candidate as (...args: unknown[]) => unknown + const capture = (values: readonly unknown[]): void => { this.captureConsole(type, values) } + const replacement = function (this: unknown, ...args: unknown[]): unknown { + const result = Reflect.apply(original, this, args) + const values = name === 'assert' ? args.slice(1) : args + if (name !== 'assert' || !args[0]) capture(values) + return result + } + if (Reflect.set(console, name, replacement)) this.installed.push({ name, original, replacement }) + } + addGlobalListener('error', this.onError) + addGlobalListener('unhandledrejection', this.onUnhandledRejection) + } + + private uninstall(): void { + if (!this.active) return + this.active = false + removeGlobalListener('error', this.onError) + removeGlobalListener('unhandledrejection', this.onUnhandledRejection) + for (const method of this.installed.splice(0).reverse()) { + if (Reflect.get(console, method.name) === method.replacement) Reflect.set(console, method.name, method.original) + } + } + + private readonly onError = (event: Event): void => { + const error = Reflect.get(event, 'error') as unknown + const message = Reflect.get(event, 'message') as unknown + this.captureException(error ?? new Error(typeof message === 'string' ? message : 'Client error')) + } + + private readonly onUnhandledRejection = (event: Event): void => { + this.captureException(Reflect.get(event, 'reason') as unknown) + } + + private captureConsole(type: RuntimeConsoleType, values: readonly unknown[]): void { + const timestamp = Date.now() + const stackTrace = captureClientConsoleStack(this.resolveScript) + queueMicrotask(() => { + for (const sessionId of [...this.sessions]) { + try { + const event = this.runtime.consoleEvent(sessionId, type, values, timestamp, stackTrace) + if (event !== undefined) this.sink(sessionId, event) + } catch { + // Console observation must not affect the page's original console call. + } + } + }) + } + + private captureException(error: unknown): void { + const timestamp = Date.now() + const stackTrace = clientErrorStack(error, this.resolveScript) + queueMicrotask(() => { + for (const sessionId of [...this.sessions]) { + try { + const event = this.runtime.exceptionEvent(sessionId, error, timestamp, stackTrace) + if (event !== undefined) this.sink(sessionId, event) + } catch { + // Exception observation must not affect browser error dispatch. + } + } + }) + } +} + +function addGlobalListener(type: string, listener: EventListener): void { + const add = Reflect.get(globalThis, 'addEventListener') as unknown + if (typeof add === 'function') Reflect.apply(add, globalThis, [type, listener]) +} + +function removeGlobalListener(type: string, listener: EventListener): void { + const remove = Reflect.get(globalThis, 'removeEventListener') as unknown + if (typeof remove === 'function') Reflect.apply(remove, globalThis, [type, listener]) +} diff --git a/packages/experimental/inspector/src/client/cdp/debugger.ts b/packages/experimental/inspector/src/client/cdp/debugger.ts new file mode 100644 index 0000000000..db720a0a33 --- /dev/null +++ b/packages/experimental/inspector/src/client/cdp/debugger.ts @@ -0,0 +1,11 @@ +/** Client active debugging is not exposed by the source bridge. */ + +import type { InspectorSourceCapability } from '../../shared/bridge/messages/observation.ts' + +/** + * Describe unavailable browser-side active debugging. + * @returns No source capability until a pause-safe Client debugger agent exists. + */ +export function debuggerBridgeCapability(): InspectorSourceCapability | undefined { + return undefined +} diff --git a/packages/experimental/inspector/src/client/cdp/errors.ts b/packages/experimental/inspector/src/client/cdp/errors.ts new file mode 100644 index 0000000000..62bab531df --- /dev/null +++ b/packages/experimental/inspector/src/client/cdp/errors.ts @@ -0,0 +1,10 @@ +/** Client Runtime failures that belong to the transport rather than evaluated JavaScript. */ + +import type { ClientRuntimeError } from '../../shared/bridge/messages/runtime/index.ts' + +/** Failure returned through the typed Client Runtime error outcome. */ +export class ClientRuntimeExecutionError extends Error { + constructor(readonly code: ClientRuntimeError['code'], message: string) { + super(message) + } +} diff --git a/packages/experimental/inspector/src/client/cdp/heap-profiler.ts b/packages/experimental/inspector/src/client/cdp/heap-profiler.ts new file mode 100644 index 0000000000..3c875822bd --- /dev/null +++ b/packages/experimental/inspector/src/client/cdp/heap-profiler.ts @@ -0,0 +1,11 @@ +/** Client heap profiling is not exposed by the source bridge. */ + +import type { InspectorSourceCapability } from '../../shared/bridge/messages/observation.ts' + +/** + * Describe unavailable browser-side heap profiling. + * @returns No source capability for Client heap profiling. + */ +export function heapProfilerBridgeCapability(): InspectorSourceCapability | undefined { + return undefined +} diff --git a/packages/experimental/inspector/src/client/cdp/index.ts b/packages/experimental/inspector/src/client/cdp/index.ts new file mode 100644 index 0000000000..a18a8612c5 --- /dev/null +++ b/packages/experimental/inspector/src/client/cdp/index.ts @@ -0,0 +1,26 @@ +/** Source-side CDP capability declarations for the browser Client realm. */ + +import type { InspectorSourceCapability } from '../../shared/bridge/messages/observation.ts' +import { consoleBridgeCapability } from './console.ts' +import { debuggerBridgeCapability } from './debugger.ts' +import { heapProfilerBridgeCapability } from './heap-profiler.ts' +import { profilerBridgeCapability } from './profiler.ts' +import { runtimeBridgeCapability } from './runtime.ts' +import { sourcesBridgeCapability } from './sources.ts' + +/** + * Describe Client operations that require Worker-to-page bridge messages. + * @param origin - Origin assigned to the synthetic execution context. + * @param hasSources - Whether the Client bundle source was discovered. + * @returns Capabilities included in the Client source handshake. + */ +export function bridgeCapabilities(origin: string, hasSources: boolean): readonly InspectorSourceCapability[] { + return [ + runtimeBridgeCapability(origin), + consoleBridgeCapability(), + sourcesBridgeCapability(hasSources), + debuggerBridgeCapability(), + profilerBridgeCapability(), + heapProfilerBridgeCapability(), + ].filter((capability): capability is InspectorSourceCapability => capability !== undefined) +} diff --git a/packages/experimental/inspector/src/client/cdp/objects.ts b/packages/experimental/inspector/src/client/cdp/objects.ts new file mode 100644 index 0000000000..25766f79a4 --- /dev/null +++ b/packages/experimental/inspector/src/client/cdp/objects.ts @@ -0,0 +1,406 @@ +/** Client-local object handles and CDP-compatible RemoteObject serialization. */ + +import { + inspectorId, + type ClientRemoteObjectHandle, +} from '../../shared/bridge/ids.ts' +import { isJsonValue, type InspectorJsonValue } from '../../shared/json.ts' +import type { ClientRuntimeRemoteObject } from '../../shared/bridge/messages/runtime/index.ts' +import type { + RuntimeObjectPreview, + RuntimePropertyPreview, + RuntimeRemoteObjectSubtype, + RuntimeRemoteObjectType, +} from '../../shared/cdp/index.ts' +import { ClientRuntimeExecutionError } from './errors.ts' +import { identifyRealmObject } from '../../shared/cordis/object-registry.ts' + +const MAX_CLASS_PROTOTYPE_DEPTH = 32 + +interface StoredObject { + readonly value: unknown + readonly group: string | undefined +} + +/** Opaque set of handles allocated by one Client Runtime operation. */ +export type ClientObjectAllocation = symbol + +/** Serialization choices inherited by child RemoteObjects. */ +export interface ClientRuntimeObjectOptions { + readonly group?: string + readonly generatePreview?: boolean + readonly returnByValue?: boolean +} + +/** Per-DevTools-session owner of all live Client object references. */ +export class ClientObjectStore { + private readonly objects = new Map() + private readonly groups = new Map>() + private readonly allocations = new Map>() + private nextOrdinal = 1 + + constructor(private readonly maxObjects: number) {} + + /** + * Start tracking handles allocated by one independently settling operation. + * @returns An opaque allocation identity. + */ + beginAllocation(): ClientObjectAllocation { + const allocation = Symbol('Client Runtime object allocation') + this.allocations.set(allocation, new Set()) + return allocation + } + + /** + * Keep an operation's handles and release its allocation bookkeeping. + * @param allocation - Allocation returned by {@link beginAllocation}. + */ + commitAllocation(allocation: ClientObjectAllocation): void { + this.allocations.delete(allocation) + } + + /** + * Resolve one handle or fail without exposing another session's objects. + * @param handle - Client-local object handle. + * @returns The retained JavaScript value. + */ + get(handle: ClientRemoteObjectHandle): unknown { + const object = this.objects.get(handle) + if (object === undefined) throw new ClientRuntimeExecutionError('object-not-found', 'Client RemoteObject was released') + return object.value + } + + /** + * Read the object group inherited by values reached through one handle. + * @param handle - Client-local object handle. + * @returns Its object group, or `undefined` when it is ungrouped. + */ + group(handle: ClientRemoteObjectHandle): string | undefined { + const object = this.objects.get(handle) + if (object === undefined) throw new ClientRuntimeExecutionError('object-not-found', 'Client RemoteObject was released') + return object.group + } + + /** + * Convert a live value to the JSON-safe RemoteObject protocol. + * @param value - Value owned by this Client realm. + * @param options - Object group and serialization options. + * @param allocation - Optional operation that owns any newly retained handle. + * @returns A primitive value or opaque Client handle with display metadata. + */ + serialize( + value: unknown, + options: ClientRuntimeObjectOptions = {}, + allocation?: ClientObjectAllocation, + ): ClientRuntimeRemoteObject { + const primitive = serializePrimitive(value) + if (primitive !== undefined) return primitive + if (options.returnByValue === true) { + return { + descriptor: { + type: typeof value === 'function' ? 'function' : 'object', + value: serializeByValue(value), + description: describe(value), + }, + } + } + const type: RuntimeRemoteObjectType = typeof value === 'function' ? 'function' : typeof value === 'symbol' ? 'symbol' : 'object' + const subtype = type === 'object' ? subtypeOf(value) : undefined + const objectReference = identifyRealmObject(value) + return { + descriptor: { + type, + ...(subtype === undefined ? {} : { subtype }), + className: className(value), + description: describe(value), + ...(options.generatePreview === true && type === 'object' ? { preview: preview(value, type, subtype) } : {}), + }, + object: { handle: this.register(value, options.group, allocation) }, + ...(objectReference === undefined ? {} : { semanticReference: objectReference }), + } + } + + /** + * Release exactly one handle. Releasing an unknown handle is idempotent. + * @param handle - Client-local object handle. + */ + release(handle: ClientRemoteObjectHandle): void { + const object = this.objects.get(handle) + if (object === undefined) return + this.objects.delete(handle) + if (object.group === undefined) return + const members = this.groups.get(object.group) + members?.delete(handle) + if (members?.size === 0) this.groups.delete(object.group) + } + + /** + * Release every handle in one DevTools object group. + * @param group - DevTools object-group name. + */ + releaseGroup(group: string): void { + const members = this.groups.get(group) + if (members === undefined) return + for (const handle of members) this.objects.delete(handle) + this.groups.delete(group) + } + + /** + * Discard exactly the handles allocated by one failed operation. + * @param allocation - Allocation returned by {@link beginAllocation}. + */ + rollback(allocation: ClientObjectAllocation): void { + const handles = this.allocations.get(allocation) + if (handles === undefined) return + this.allocations.delete(allocation) + for (const handle of handles) this.release(handle) + } + + /** Release the whole DevTools session. */ + clear(): void { + this.objects.clear() + this.groups.clear() + this.allocations.clear() + } + + private register( + value: unknown, + group: string | undefined, + allocation: ClientObjectAllocation | undefined, + ): ClientRemoteObjectHandle { + if (this.objects.size >= this.maxObjects) { + throw new ClientRuntimeExecutionError('result-too-large', `Client Runtime retained-object limit ${String(this.maxObjects)} reached`) + } + const ordinal = this.nextOrdinal++ + const handle = inspectorId<'ClientRemoteObjectHandle'>(`object-${String(ordinal)}`, 'handle') + this.objects.set(handle, { value, group }) + if (allocation !== undefined) this.allocations.get(allocation)?.add(handle) + if (group !== undefined) { + let members = this.groups.get(group) + if (members === undefined) { + members = new Set() + this.groups.set(group, members) + } + members.add(handle) + } + return handle + } +} + +function serializePrimitive(value: unknown): ClientRuntimeRemoteObject | undefined { + if (value === undefined) return { descriptor: { type: 'undefined' } } + if (value === null) return { descriptor: { type: 'object', subtype: 'null', value: null } } + if (typeof value === 'string') return { descriptor: { type: 'string', value } } + if (typeof value === 'boolean') return { descriptor: { type: 'boolean', value } } + if (typeof value === 'bigint') { + const text = `${String(value)}n` + return { descriptor: { type: 'bigint', unserializableValue: text, description: text } } + } + if (typeof value !== 'number') return undefined + if (Number.isFinite(value) && !Object.is(value, -0)) { + return { descriptor: { type: 'number', value, description: String(value) } } + } + const text = Object.is(value, -0) ? '-0' : String(value) + return { descriptor: { type: 'number', unserializableValue: text, description: text } } +} + +function serializeByValue(value: unknown): InspectorJsonValue { + let serialized: unknown + try { + serialized = JSON.stringify(value) + } catch (error) { + throw new ClientRuntimeExecutionError('unsupported', `Value cannot be returned by value: ${renderError(error)}`) + } + if (typeof serialized !== 'string') throw new ClientRuntimeExecutionError('unsupported', 'Value cannot be returned by value') + const result = JSON.parse(serialized) as unknown + if (!isJsonValue(result)) throw new ClientRuntimeExecutionError('unsupported', 'Value is outside the JSON value set') + return result +} + +function preview( + value: unknown, + type: RuntimeRemoteObjectType, + subtype: RuntimeRemoteObjectSubtype | undefined, +): RuntimeObjectPreview { + const properties: RuntimePropertyPreview[] = [] + let overflow = false + if ((typeof value === 'object' && value !== null) || typeof value === 'function') { + let keys: readonly PropertyKey[] = [] + try { + keys = Reflect.ownKeys(value) + } catch { + overflow = true + } + for (const key of keys) { + if (properties.length === 5) { + overflow = true + break + } + let descriptor: PropertyDescriptor | undefined + try { + descriptor = Reflect.getOwnPropertyDescriptor(value, key) + } catch { + continue + } + if (descriptor === undefined) continue + if (!('value' in descriptor)) { + properties.push({ name: String(key), type: 'accessor' }) + continue + } + const propertyType = remoteType(descriptor.value) + const propertySubtype = propertyType === 'object' ? subtypeOf(descriptor.value) : undefined + properties.push({ + name: String(key), + type: propertyType, + value: previewText(descriptor.value), + ...(propertySubtype === undefined ? {} : { subtype: propertySubtype }), + }) + } + } + return { + type, + ...(subtype === undefined ? {} : { subtype }), + description: describe(value), + overflow, + properties, + } +} + +function remoteType(value: unknown): RuntimeRemoteObjectType { + if (value === null) return 'object' + return typeof value +} + +function subtypeOf(value: unknown): RuntimeRemoteObjectSubtype | undefined { + if (value === null) return 'null' + if (Array.isArray(value)) return 'array' + if (ArrayBuffer.isView(value)) return value instanceof DataView ? 'dataview' : 'typedarray' + if (typeof value !== 'object') return undefined + for (const [prototype, subtype] of SUBTYPES_BY_PROTOTYPE) { + if (inheritsFrom(value, prototype)) return subtype + } + return undefined +} + +function className(value: unknown): string { + if (typeof value === 'function') return functionName(value) + if (typeof value === 'symbol') return 'Symbol' + if (typeof value !== 'object' || value === null) return 'Object' + const visited = new Set() + let prototype = prototypeOf(value) + while (prototype !== null && visited.size < MAX_CLASS_PROTOTYPE_DEPTH && !visited.has(prototype)) { + visited.add(prototype) + const constructor = Reflect.getOwnPropertyDescriptor(prototype, 'constructor') + const candidate: unknown = constructor !== undefined && 'value' in constructor ? constructor.value : undefined + if (typeof candidate === 'function') { + return functionName(candidate) + } + prototype = prototypeOf(prototype) + } + return 'Object' +} + +function describe(value: unknown): string { + if (typeof value === 'function') { + try { + return Function.prototype.toString.call(value) + } catch { + return functionName(value) + } + } + const subtype = subtypeOf(value) + if (subtype === 'array') { + const descriptor = Reflect.getOwnPropertyDescriptor(value as object, 'length') + const length: unknown = descriptor !== undefined && 'value' in descriptor ? descriptor.value : undefined + return `Array(${typeof length === 'number' ? String(length) : '?'})` + } + if (subtype === 'error') { + const stack = ownString(value as object, 'stack') + if (stack !== undefined) return stack + const name = ownString(value as object, 'name') ?? className(value) + const message = ownString(value as object, 'message') + return message === undefined || message.length === 0 ? name : `${name}: ${message}` + } + if (subtype === 'date') { + try { + return Date.prototype.toString.call(value) + } catch { + return 'Date' + } + } + if (subtype === 'regexp') { + try { + return RegExp.prototype.toString.call(value) + } catch { + return 'RegExp' + } + } + return className(value) +} + +function previewText(value: unknown): string { + if (typeof value === 'string') return value.slice(0, 100) + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint' || typeof value === 'symbol') { + return String(value) + } + if (value === null) return 'null' + if (value === undefined) return 'undefined' + return describe(value).slice(0, 100) +} + +function functionName(value: object): string { + try { + const descriptor = Reflect.getOwnPropertyDescriptor(value, 'name') + const name: unknown = descriptor !== undefined && 'value' in descriptor ? descriptor.value : undefined + return typeof name === 'string' && name.length > 0 ? name : 'Function' + } catch { + return 'Function' + } +} + +function prototypeOf(value: object): object | null { + try { + return Reflect.getPrototypeOf(value) + } catch { + return null + } +} + +function inheritsFrom(value: object, expected: object): boolean { + const visited = new Set() + let current = prototypeOf(value) + while (current !== null && visited.size < MAX_CLASS_PROTOTYPE_DEPTH && !visited.has(current)) { + if (current === expected) return true + visited.add(current) + current = prototypeOf(current) + } + return false +} + +function ownString(value: object, key: string): string | undefined { + try { + const descriptor = Reflect.getOwnPropertyDescriptor(value, key) + return descriptor !== undefined && 'value' in descriptor && typeof descriptor.value === 'string' + ? descriptor.value + : undefined + } catch { + return undefined + } +} + +function renderError(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +const SUBTYPES_BY_PROTOTYPE: readonly (readonly [object, RuntimeRemoteObjectSubtype])[] = [ + [RegExp.prototype, 'regexp'], + [Date.prototype, 'date'], + [Map.prototype, 'map'], + [Set.prototype, 'set'], + [WeakMap.prototype, 'weakmap'], + [WeakSet.prototype, 'weakset'], + [Error.prototype, 'error'], + [Promise.prototype, 'promise'], + [ArrayBuffer.prototype, 'arraybuffer'], + [DataView.prototype, 'dataview'], +] diff --git a/packages/experimental/inspector/src/client/cdp/profiler.ts b/packages/experimental/inspector/src/client/cdp/profiler.ts new file mode 100644 index 0000000000..d303fe7acc --- /dev/null +++ b/packages/experimental/inspector/src/client/cdp/profiler.ts @@ -0,0 +1,11 @@ +/** Client CPU profiling is not exposed by the source bridge. */ + +import type { InspectorSourceCapability } from '../../shared/bridge/messages/observation.ts' + +/** + * Describe unavailable browser-side CPU profiling. + * @returns No source capability for Client CPU profiling. + */ +export function profilerBridgeCapability(): InspectorSourceCapability | undefined { + return undefined +} diff --git a/packages/experimental/inspector/src/client/cdp/properties.ts b/packages/experimental/inspector/src/client/cdp/properties.ts new file mode 100644 index 0000000000..ceb0faa6ae --- /dev/null +++ b/packages/experimental/inspector/src/client/cdp/properties.ts @@ -0,0 +1,160 @@ +/** Lazy Client property enumeration for `Runtime.getProperties`. */ + +import type { + ClientRuntimeGetPropertiesCommand, + ClientRuntimeInternalPropertyDescriptor, + ClientRuntimePropertyDescriptor, +} from '../../shared/bridge/messages/runtime/index.ts' +import { ClientRuntimeExecutionError } from './errors.ts' +import { ClientObjectStore, type ClientObjectAllocation } from './objects.ts' + +/** + * Read property descriptors without invoking getters. + * @param objects - Object table that owns the requested handle. + * @param command - Validated property request. + * @param maxProperties - Maximum descriptors returned by this operation. + * @param allocation - Current operation's object-allocation identity. + * @returns Own or inherited descriptors and the immediate prototype. + */ +export function getClientProperties( + objects: ClientObjectStore, + command: ClientRuntimeGetPropertiesCommand, + maxProperties: number, + allocation: ClientObjectAllocation, +): { + readonly properties: readonly ClientRuntimePropertyDescriptor[] + readonly internalProperties?: readonly ClientRuntimeInternalPropertyDescriptor[] +} { + const raw = objects.get(command.handle) + if (!isObjectLike(raw)) return { properties: [] } + const value: object = typeof raw === 'symbol' ? Symbol.prototype : raw + const group = objects.group(command.handle) + const properties: ClientRuntimePropertyDescriptor[] = [] + const seen = new Set() + const visited = new Set() + let owner: object | null = value + let own = true + + while (owner !== null) { + if (visited.has(owner) || visited.size >= maxProperties) { + throw new ClientRuntimeExecutionError('result-too-large', 'Client prototype traversal exceeded its configured limit') + } + visited.add(owner) + const keys = readKeys(owner) + for (const key of keys) { + if (seen.has(key)) continue + seen.add(key) + if (command.nonIndexedPropertiesOnly === true && typeof key === 'string' && isArrayIndex(key)) continue + const descriptor = readDescriptor(owner, key) + if (descriptor === undefined) continue + if (command.accessorPropertiesOnly === true && 'value' in descriptor) continue + if (properties.length >= maxProperties) { + throw new ClientRuntimeExecutionError( + 'result-too-large', + `Client property result exceeds the configured ${String(maxProperties)}-property limit`, + ) + } + properties.push(toRemoteDescriptor( + objects, + key, + descriptor, + group, + own, + command.generatePreview === true, + allocation, + )) + } + if (command.ownProperties === true) break + owner = readPrototype(owner) + own = false + } + + if (command.accessorPropertiesOnly === true) return { properties } + const prototype = readPrototype(value) + const internalProperties: ClientRuntimeInternalPropertyDescriptor[] = prototype === null + ? [] + : [{ + name: '[[Prototype]]', + value: objects.serialize(prototype, remoteOptions(group, command.generatePreview), allocation), + }] + return { properties, internalProperties } +} + +function toRemoteDescriptor( + objects: ClientObjectStore, + key: PropertyKey, + descriptor: PropertyDescriptor, + group: string | undefined, + own: boolean, + generatePreview: boolean, + allocation: ClientObjectAllocation, +): ClientRuntimePropertyDescriptor { + const common = { + name: typeof key === 'symbol' ? key.description ?? String(key) : String(key), + configurable: descriptor.configurable ?? false, + enumerable: descriptor.enumerable ?? false, + isOwn: own, + ...(typeof key === 'symbol' ? { symbol: objects.serialize(key, remoteOptions(group), allocation) } : {}), + } + if ('value' in descriptor) { + return { + ...common, + value: objects.serialize(descriptor.value, remoteOptions(group, generatePreview), allocation), + writable: descriptor.writable ?? false, + } + } + const getter = Reflect.get(descriptor, 'get') as (() => unknown) | undefined + const setter = Reflect.get(descriptor, 'set') as ((value: unknown) => void) | undefined + return { + ...common, + ...(getter === undefined ? {} : { get: objects.serialize(getter, remoteOptions(group), allocation) }), + ...(setter === undefined ? {} : { set: objects.serialize(setter, remoteOptions(group), allocation) }), + } +} + +function readKeys(value: object): readonly PropertyKey[] { + try { + return Reflect.ownKeys(value) + } catch (error) { + throw new ClientRuntimeExecutionError('internal-error', `Cannot enumerate Client object: ${renderError(error)}`) + } +} + +function readDescriptor(value: object, key: PropertyKey): PropertyDescriptor | undefined { + try { + return Reflect.getOwnPropertyDescriptor(value, key) + } catch (error) { + throw new ClientRuntimeExecutionError('internal-error', `Cannot read Client property ${String(key)}: ${renderError(error)}`) + } +} + +function readPrototype(value: object): object | null { + try { + return Object.getPrototypeOf(value) as object | null + } catch (error) { + throw new ClientRuntimeExecutionError('internal-error', `Cannot read Client object prototype: ${renderError(error)}`) + } +} + +function isObjectLike(value: unknown): value is object | symbol { + return (typeof value === 'object' && value !== null) || typeof value === 'function' || typeof value === 'symbol' +} + +function isArrayIndex(value: string): boolean { + const number = Number(value) + return Number.isInteger(number) && number >= 0 && number < 4_294_967_295 && String(number) === value +} + +function renderError(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function remoteOptions(group: string | undefined, generatePreview?: boolean): { + readonly group?: string + readonly generatePreview?: boolean +} { + return { + ...(group === undefined ? {} : { group }), + ...(generatePreview === undefined ? {} : { generatePreview }), + } +} diff --git a/packages/experimental/inspector/src/client/cdp/runtime.ts b/packages/experimental/inspector/src/client/cdp/runtime.ts new file mode 100644 index 0000000000..b72a73e2fc --- /dev/null +++ b/packages/experimental/inspector/src/client/cdp/runtime.ts @@ -0,0 +1,423 @@ +/** Client-realm executor for the typed Runtime command protocol. */ + +import type { + ClientCallArgument, + ClientRuntimeCapability, + ClientRuntimeCommand, + ClientRuntimeCompletion, + ClientRuntimeError, + ClientRuntimeExceptionDetails, + ClientRuntimeRequestFrame, + ClientRuntimeResponseFrame, + ClientRuntimeResult, + ClientRuntimeRemoteObject, +} from '../../shared/bridge/messages/runtime/index.ts' +import type { ClientRemoteObjectHandle, ClientRuntimeSessionId } from '../../shared/bridge/ids.ts' +import { isJsonValue, jsonByteLength } from '../../shared/json.ts' +import { INSPECTOR_PROTOCOL_VERSION } from '../../shared/bridge/version.ts' +import { ClientRuntimeExecutionError } from './errors.ts' +import type { RuntimeConsoleBackendEvent, RuntimeConsoleType, RuntimeStackTrace } from '../../shared/cdp/index.ts' +import { ClientObjectStore, type ClientObjectAllocation } from './objects.ts' +import { getClientProperties } from './properties.ts' +import { clientErrorStack, type ClientScriptKeyResolver } from './stack.ts' + +const MAX_RUNTIME_ERROR_MESSAGE_LENGTH = 2_048 + +/** + * Describe browser-side Runtime execution. + * @param origin - Origin assigned to the synthetic execution context. + * @returns The Runtime capability advertised by a browser Client source. + */ +export function runtimeBridgeCapability(origin: string): ClientRuntimeCapability { + return { type: 'client-runtime', origin } +} + +/** Client-side limits injected by the Host deployment. */ +export interface ClientRuntimeLimits { + readonly maxObjectsPerSession: number + readonly maxPropertiesPerResult: number + readonly maxResponseBytes: number +} + +/** Executes Runtime requests while isolating object handles by DevTools session. */ +export class ClientRuntimeExecutor { + private readonly sessions = new Map() + + constructor( + private readonly limits: ClientRuntimeLimits, + private readonly resolveScript: ClientScriptKeyResolver = () => undefined, + ) {} + + /** + * Execute one request and preserve its source, generation, session, and request identities. + * @param frame - Validated command envelope from the Worker. + * @returns A success or transport-error response for the same request. + */ + async execute(frame: ClientRuntimeRequestFrame): Promise { + const session = this.session(frame.sessionId) + const allocation = session.beginAllocation() + try { + const result = await session.execute(frame.command, allocation) + const response = responseFrame(frame, { ok: true, result }) + if (!isJsonValue(response) || jsonByteLength(response) > this.limits.maxResponseBytes) { + session.rollback(allocation) + return responseFrame(frame, { + ok: false, + error: { code: 'result-too-large', message: 'Client Runtime result exceeds the source-frame byte limit' }, + }) + } + session.commitAllocation(allocation) + return response + } catch (error) { + session.rollback(allocation) + return responseFrame(frame, { ok: false, error: runtimeError(error) }) + } + } + + /** + * Release all values retained for one closed DevTools connection. + * @param sessionId - Runtime session owned by that DevTools connection. + */ + closeSession(sessionId: ClientRuntimeSessionId): void { + this.sessions.get(sessionId)?.close() + this.sessions.delete(sessionId) + } + + /** + * Release one object group without closing the surrounding Runtime session. + * @param sessionId - Session that owns the retained objects. + * @param group - Object-group name to release. + */ + releaseObjectGroup(sessionId: ClientRuntimeSessionId, group: string): void { + this.sessions.get(sessionId)?.releaseObjectGroup(group) + } + + /** + * Serialize one Console call for a specific DevTools Runtime session. + * @param sessionId - Session receiving the Console event. + * @param type - Console API operation. + * @param values - Original arguments from the page call. + * @param timestamp - Epoch timestamp in milliseconds. + * @param stackTrace - Browser call frames captured before deferred delivery. + * @returns A wire-safe event whose object handles belong only to this session. + */ + consoleEvent( + sessionId: ClientRuntimeSessionId, + type: RuntimeConsoleType, + values: readonly unknown[], + timestamp: number, + stackTrace?: RuntimeStackTrace, + ): RuntimeConsoleBackendEvent | undefined { + const session = this.session(sessionId) + const allocation = session.beginAllocation() + try { + const event: RuntimeConsoleBackendEvent = { + type: 'console-api', + event: { + type, + arguments: session.serializeAll(values, 'console', allocation), + timestamp, + ...(stackTrace === undefined ? {} : { stackTrace }), + }, + } + if (!isJsonValue(event) || jsonByteLength(event) + 4_096 > this.limits.maxResponseBytes) { + session.rollback(allocation) + return undefined + } + session.commitAllocation(allocation) + return event + } catch (error) { + session.rollback(allocation) + throw error + } + } + + /** + * Serialize one uncaught Client exception for a DevTools Runtime session. + * @param sessionId - Session receiving the exception event. + * @param error - Thrown or rejected value. + * @param timestamp - Epoch timestamp in milliseconds. + * @param stackTrace - Browser call frames attached to the failure. + * @returns A wire-safe exception event. + */ + exceptionEvent( + sessionId: ClientRuntimeSessionId, + error: unknown, + timestamp: number, + stackTrace?: RuntimeStackTrace, + ): RuntimeConsoleBackendEvent | undefined { + const session = this.session(sessionId) + const allocation = session.beginAllocation() + try { + const event: RuntimeConsoleBackendEvent = { + type: 'exception', + event: { + timestamp, + details: session.describeException(error, 'console', stackTrace, allocation), + }, + } + if (!isJsonValue(event) || jsonByteLength(event) + 4_096 > this.limits.maxResponseBytes) { + session.rollback(allocation) + return undefined + } + session.commitAllocation(allocation) + return event + } catch (serializationError) { + session.rollback(allocation) + throw serializationError + } + } + + /** Release all sessions when a source generation ends or reconnects. */ + reset(): void { + for (const session of this.sessions.values()) session.close() + this.sessions.clear() + } + + private session(sessionId: ClientRuntimeSessionId): ClientRuntimeSession { + let session = this.sessions.get(sessionId) + if (session === undefined) { + session = new ClientRuntimeSession( + this.limits.maxObjectsPerSession, + this.limits.maxPropertiesPerResult, + this.resolveScript, + ) + this.sessions.set(sessionId, session) + } + return session + } +} + +class ClientRuntimeSession { + private readonly objects: ClientObjectStore + + constructor( + maxObjects: number, + private readonly maxProperties: number, + private readonly resolveScript: ClientScriptKeyResolver, + ) { + this.objects = new ClientObjectStore(maxObjects) + } + + beginAllocation(): ClientObjectAllocation { + return this.objects.beginAllocation() + } + + commitAllocation(allocation: ClientObjectAllocation): void { + this.objects.commitAllocation(allocation) + } + + rollback(allocation: ClientObjectAllocation): void { + this.objects.rollback(allocation) + } + + async execute(command: ClientRuntimeCommand, allocation: ClientObjectAllocation): Promise { + switch (command.op) { + case 'evaluate': + return { op: command.op, completion: await this.evaluate(command, allocation) } + case 'get-properties': { + const result = getClientProperties(this.objects, command, this.maxProperties, allocation) + return { op: command.op, ...result } + } + case 'call-function': + return { op: command.op, completion: await this.callFunction(command, allocation) } + case 'await-promise': + return { op: command.op, completion: await this.awaitPromise(command, allocation) } + case 'release-object': + this.objects.release(command.handle) + return { op: command.op } + case 'release-object-group': + this.releaseObjectGroup(command.objectGroup) + return { op: command.op } + case 'global-lexical-scope-names': + return { op: command.op, names: [] } + default: + return assertNever(command) + } + } + + close(): void { + this.objects.clear() + } + + releaseObjectGroup(group: string): void { + this.objects.releaseGroup(group) + } + + serializeAll( + values: readonly unknown[], + group: string, + allocation: ClientObjectAllocation, + ): ClientRuntimeRemoteObject[] { + return values.map(value => this.objects.serialize(value, { group, generatePreview: true }, allocation)) + } + + describeException( + error: unknown, + group: string | undefined, + stackTrace?: RuntimeStackTrace, + allocation?: ClientObjectAllocation, + ): ClientRuntimeExceptionDetails { + const options = { ...(group === undefined ? {} : { group }) } + const resolvedStackTrace = stackTrace ?? clientErrorStack(error, this.resolveScript) + const firstFrame = resolvedStackTrace?.callFrames[0] + return { + text: 'Uncaught', + lineNumber: firstFrame?.lineNumber ?? 0, + columnNumber: firstFrame?.columnNumber ?? 0, + ...(firstFrame === undefined ? clientUrl() : { url: firstFrame.url }), + ...(resolvedStackTrace === undefined ? {} : { stackTrace: resolvedStackTrace }), + exception: this.objects.serialize(error, options, allocation), + } + } + + private async evaluate( + command: Extract, + allocation: ClientObjectAllocation, + ): Promise { + let value: unknown + try { + value = globalThis.eval(command.expression) as unknown + if (command.awaitPromise === true) value = await awaitWithTimeout(value, command.timeoutMs) + } catch (error) { + if (error instanceof ClientRuntimeExecutionError) throw error + return this.exception(error, command.objectGroup, allocation) + } + return this.completion( + value, + allocation, + command.objectGroup, + command.generatePreview, + command.returnByValue, + ) + } + + private async callFunction( + command: Extract, + allocation: ClientObjectAllocation, + ): Promise { + const receiver = command.receiver === undefined ? globalThis : this.objects.get(command.receiver) + const inheritedGroup = command.receiver === undefined ? undefined : this.objects.group(command.receiver) + const group = command.objectGroup ?? inheritedGroup + const args = (command.arguments ?? []).map(argument => this.resolveArgument(argument)) + let value: unknown + try { + const fn = globalThis.eval(`(${command.functionDeclaration}\n)`) as unknown + if (typeof fn !== 'function') throw new TypeError('functionDeclaration did not evaluate to a function') + value = Reflect.apply(fn, receiver, args) + if (command.awaitPromise === true) value = await value + } catch (error) { + return this.exception(error, group, allocation) + } + return this.completion(value, allocation, group, command.generatePreview, command.returnByValue) + } + + private async awaitPromise( + command: Extract, + allocation: ClientObjectAllocation, + ): Promise { + const group = this.objects.group(command.promise) + let value: unknown + try { + value = await this.objects.get(command.promise) + } catch (error) { + if (error instanceof ClientRuntimeExecutionError) throw error + return this.exception(error, group, allocation) + } + return this.completion(value, allocation, group, command.generatePreview, command.returnByValue) + } + + private resolveArgument(argument: ClientCallArgument): unknown { + switch (argument.kind) { + case 'value': return argument.value + case 'object': return this.objects.get(argument.handle) + case 'undefined': return undefined + case 'unserializable': return parseUnserializable(argument.value) + default: return assertNever(argument) + } + } + + private exception( + error: unknown, + group: string | undefined, + allocation: ClientObjectAllocation, + ): ClientRuntimeCompletion { + const options = { ...(group === undefined ? {} : { group }) } + const details = this.describeException(error, group, undefined, allocation) + return { result: this.objects.serialize(error, options, allocation), exceptionDetails: details } + } + + private completion( + value: unknown, + allocation: ClientObjectAllocation, + group: string | undefined, + generatePreview: boolean | undefined, + returnByValue: boolean | undefined, + ): ClientRuntimeCompletion { + return { + result: this.objects.serialize(value, { + ...(group === undefined ? {} : { group }), + ...(generatePreview === undefined ? {} : { generatePreview }), + ...(returnByValue === undefined ? {} : { returnByValue }), + }, allocation), + } + } +} + +function responseFrame( + request: ClientRuntimeRequestFrame, + outcome: ClientRuntimeResponseFrame['outcome'], +): ClientRuntimeResponseFrame { + return { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'client-runtime/response', + sourceId: request.sourceId, + generation: request.generation, + sessionId: request.sessionId, + requestId: request.requestId, + outcome, + } +} + +function runtimeError(error: unknown): ClientRuntimeError { + const code = error instanceof ClientRuntimeExecutionError ? error.code : 'internal-error' + const message = error instanceof Error ? error.message : String(error) + return { code, message: message.slice(0, MAX_RUNTIME_ERROR_MESSAGE_LENGTH) } +} + +function parseUnserializable(value: string): unknown { + if (value === 'NaN') return Number.NaN + if (value === 'Infinity') return Number.POSITIVE_INFINITY + if (value === '-Infinity') return Number.NEGATIVE_INFINITY + if (value === '-0') return -0 + if (/^-?(?:0|[1-9]\d*)n$/u.test(value)) return BigInt(value.slice(0, -1)) + throw new ClientRuntimeExecutionError('invalid-request', `Unsupported unserializable value ${JSON.stringify(value)}`) +} + +function clientUrl(): { readonly url?: string } { + const location = Reflect.get(globalThis, 'location') as unknown + if (typeof location !== 'object' || location === null) return {} + const href = Reflect.get(location, 'href') as unknown + return typeof href === 'string' ? { url: href } : {} +} + +async function awaitWithTimeout(value: unknown, timeoutMs: number | undefined): Promise { + if (timeoutMs === undefined) return await value + let timer: ReturnType | undefined + try { + return await Promise.race([ + Promise.resolve(value), + new Promise((_resolve, reject) => { + timer = setTimeout(() => { + reject(new ClientRuntimeExecutionError('timeout', `Client evaluation exceeded ${String(timeoutMs)}ms`)) + }, timeoutMs) + }), + ]) + } finally { + if (timer !== undefined) clearTimeout(timer) + } +} + +function assertNever(value: never): never { + throw new Error(`Unexpected Client Runtime variant: ${JSON.stringify(value)}`) +} diff --git a/packages/experimental/inspector/src/client/cdp/sources.ts b/packages/experimental/inspector/src/client/cdp/sources.ts new file mode 100644 index 0000000000..23d63909a9 --- /dev/null +++ b/packages/experimental/inspector/src/client/cdp/sources.ts @@ -0,0 +1,233 @@ +/** Browser-side catalog for the Inspector Client bundle and its source map. */ + +import { bytesToBase64 } from '@deepseek-ai/dsh-util-crypto' +import type { + ClientScriptDescriptor, + ClientSourceCommand, + ClientSourceError, + ClientSourceResult, + ClientSourcesCapability, +} from '../../shared/bridge/messages/sources/index.ts' +import { inspectorId } from '../../shared/identity.ts' +import type { RuntimeScriptKey } from '../../shared/cdp/ids.ts' + +const PACKAGE_ID = '@deepseek-ai/dsh-experimental-inspector' +const CLIENT_SCRIPT_KEY = inspectorId<'RuntimeScriptKey'>('client-bundle', 'scriptKey') + +/** + * Describe browser-side source access. + * @param available - Whether the Client bundle was discovered. + * @returns The Sources capability when this Client discovered its bundle. + */ +export function sourcesBridgeCapability(available: boolean): ClientSourcesCapability | undefined { + return available ? { type: 'client-sources' } : undefined +} + +/** One lazily loaded browser script exposed by a Client source catalog. */ +export interface ClientSourceAsset { + readonly scriptKey: RuntimeScriptKey + readonly url: string + readonly hash: string + readonly sourceMapUrl?: string + readonly isModule?: boolean + loadSource(): Promise + loadSourceMap?(): Promise +} + +interface LoadedAsset { + readonly asset: ClientSourceAsset + source?: Promise + sourceBytes?: Promise + sourceMapBytes?: Promise +} + +/** Deliberate error serialized by the Client source transport. */ +export class ClientSourceCatalogError extends Error { + constructor(readonly code: ClientSourceError['code'], message: string) { + super(message) + } +} + +/** Executes bounded, read-only operations over Client script assets. */ +export class ClientSourceCatalog { + private readonly assets = new Map() + + constructor(assets: readonly ClientSourceAsset[]) { + for (const asset of assets) { + if (this.assets.has(asset.scriptKey)) { + throw new Error(`inspector: duplicate Client script key ${asset.scriptKey}`) + } + this.assets.set(asset.scriptKey, { asset }) + } + } + + /** + * Resolve a stack-frame URL to this catalog's local script key. + * @param url - Absolute or page-relative stack-frame URL. + * @returns The matching script key when the URL belongs to this catalog. + */ + scriptKeyForUrl(url: string): RuntimeScriptKey | undefined { + const normalized = normalizedUrl(url) + for (const entry of this.assets.values()) { + if (normalizedUrl(entry.asset.url) === normalized) return entry.asset.scriptKey + } + return undefined + } + + /** + * Execute one validated source operation. + * @param command - Read-only catalog command. + * @param maxContentBytes - Maximum encoded bytes admitted for one asset. + * @returns Script metadata or one bounded content chunk. + */ + async execute(command: ClientSourceCommand, maxContentBytes: number): Promise { + if (command.op === 'list-scripts') { + return { + op: command.op, + scripts: await Promise.all([...this.assets.values()].map(async entry => this.describe(entry, maxContentBytes))), + } + } + const entry = this.assets.get(command.scriptKey) + if (entry === undefined) throw new ClientSourceCatalogError('script-not-found', 'Client script is not available') + const bytes = command.content === 'source' + ? await this.sourceBytes(entry, maxContentBytes) + : await this.sourceMapBytes(entry, maxContentBytes) + if (bytes === undefined) { + return { + op: command.op, + scriptKey: command.scriptKey, + content: command.content, + available: false, + } + } + if (command.offset > bytes.byteLength) { + throw new ClientSourceCatalogError('invalid-request', 'Client source chunk offset exceeds content length') + } + const nextOffset = Math.min(bytes.byteLength, command.offset + command.maxBytes) + return { + op: command.op, + scriptKey: command.scriptKey, + content: command.content, + available: true, + offset: command.offset, + nextOffset, + data: bytesToBase64(bytes.subarray(command.offset, nextOffset)), + eof: nextOffset === bytes.byteLength, + } + } + + private async describe(entry: LoadedAsset, maxContentBytes: number): Promise { + const source = await this.source(entry, maxContentBytes) + const newline = source.lastIndexOf('\n') + return { + scriptKey: entry.asset.scriptKey, + url: entry.asset.url, + hash: entry.asset.hash, + buildId: '', + ...(entry.asset.sourceMapUrl === undefined ? {} : { sourceMapUrl: entry.asset.sourceMapUrl }), + startLine: 0, + startColumn: 0, + endLine: countNewlines(source), + endColumn: newline === -1 ? source.length : source.length - newline - 1, + ...(entry.asset.isModule === undefined ? {} : { isModule: entry.asset.isModule }), + length: source.length, + } + } + + private source(entry: LoadedAsset, maxContentBytes: number): Promise { + entry.source ??= entry.asset.loadSource().catch((error: unknown) => { + throw new ClientSourceCatalogError('load-failed', `Cannot load Client script: ${renderError(error)}`) + }) + return entry.source.then((source) => { + if (new TextEncoder().encode(source).byteLength > maxContentBytes) { + throw new ClientSourceCatalogError('result-too-large', 'Client script exceeds the configured content limit') + } + return source + }) + } + + private sourceBytes(entry: LoadedAsset, maxContentBytes: number): Promise { + entry.sourceBytes ??= this.source(entry, maxContentBytes).then(source => new TextEncoder().encode(source)) + return entry.sourceBytes + } + + private sourceMapBytes(entry: LoadedAsset, maxContentBytes: number): Promise { + if (entry.asset.loadSourceMap === undefined) return Promise.resolve(undefined) + entry.sourceMapBytes ??= entry.asset.loadSourceMap().then(value => + value === undefined ? undefined : new TextEncoder().encode(value), + ).catch((error: unknown) => { + throw new ClientSourceCatalogError('load-failed', `Cannot load Client source map: ${renderError(error)}`) + }) + return entry.sourceMapBytes.then((bytes) => { + if (bytes !== undefined && bytes.byteLength > maxContentBytes) { + throw new ClientSourceCatalogError('result-too-large', 'Client source map exceeds the configured content limit') + } + return bytes + }) + } +} + +/** + * Discover this package's bundle URL from the Host-injected web boot graph. + * @returns A lazy catalog, or `undefined` outside the assembled web application. + */ +export function discoverInspectorClientSourceCatalog(): ClientSourceCatalog | undefined { + const graph = Reflect.get(globalThis, '__DSH_BOOT__') as unknown + if (typeof graph !== 'object' || graph === null) return undefined + const entries = Reflect.get(graph, 'entries') as unknown + if (!Array.isArray(entries)) return undefined + const row = entries.find((value) => { + if (typeof value !== 'object' || value === null) return false + return Reflect.get(value, 'id') === PACKAGE_ID + }) as Record | undefined + if (row === undefined || typeof row.url !== 'string' || typeof row.rev !== 'string') return undefined + const base = browserLocation() + if (base === undefined) return undefined + const sourceUrl = new URL(row.url, base) + const sourceMapUrl = new URL(sourceUrl.href) + sourceMapUrl.pathname = `${sourceMapUrl.pathname}.map` + return new ClientSourceCatalog([{ + scriptKey: CLIENT_SCRIPT_KEY, + url: sourceUrl.href, + hash: row.rev, + sourceMapUrl: sourceMapUrl.href, + isModule: false, + loadSource: async () => fetchText(sourceUrl.href), + loadSourceMap: async () => fetchText(sourceMapUrl.href), + }]) +} + +async function fetchText(url: string): Promise { + const response = await fetch(url) + if (!response.ok) throw new Error(`${String(response.status)} ${response.statusText}`) + return response.text() +} + +function browserLocation(): string | undefined { + const location = Reflect.get(globalThis, 'location') as unknown + if (typeof location !== 'object' || location === null) return undefined + const href = Reflect.get(location, 'href') as unknown + return typeof href === 'string' ? href : undefined +} + +function countNewlines(value: string): number { + let count = 0 + for (let index = 0; index < value.length; index++) { + if (value.charCodeAt(index) === 10) count++ + } + return count +} + +function renderError(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function normalizedUrl(value: string): string { + try { + const url = new URL(value, browserLocation()) + url.hash = '' + return url.href + } catch { + return value + } +} diff --git a/packages/experimental/inspector/src/client/cdp/stack.ts b/packages/experimental/inspector/src/client/cdp/stack.ts new file mode 100644 index 0000000000..5386bbc9ef --- /dev/null +++ b/packages/experimental/inspector/src/client/cdp/stack.ts @@ -0,0 +1,78 @@ +/** Browser stack parsing for realm-neutral Runtime and Console events. */ + +import type { RuntimeScriptKey } from '../../shared/cdp/ids.ts' +import type { RuntimeCallFrame, RuntimeStackTrace } from '../../shared/cdp/index.ts' + +/** Resolve a browser stack-frame URL to a Client catalog script key. */ +export type ClientScriptKeyResolver = (url: string) => RuntimeScriptKey | undefined + +/** + * Capture the caller stack of a wrapped Client Console method. + * @param resolveScript - Resolver for Client catalog script keys. + * @returns Parsed call frames when the browser supplies a stack. + */ +export function captureClientConsoleStack(resolveScript: ClientScriptKeyResolver): RuntimeStackTrace | undefined { + return parseClientStack(new Error().stack, resolveScript, 3) +} + +/** + * Parse the stack attached to an uncaught Client value when available. + * @param value - Thrown or rejected value. + * @param resolveScript - Resolver for Client catalog script keys. + * @returns Parsed call frames when the value has a recognized stack string. + */ +export function clientErrorStack( + value: unknown, + resolveScript: ClientScriptKeyResolver = () => undefined, +): RuntimeStackTrace | undefined { + if (typeof value !== 'object' || value === null) return undefined + let stack: unknown + try { + stack = Reflect.get(value, 'stack') as unknown + } catch { + // A thrown proxy or stack getter cannot replace the original JavaScript exception. + return undefined + } + return typeof stack === 'string' ? parseClientStack(stack, resolveScript, 0) : undefined +} + +/** + * Parse V8- and Firefox-style textual frames into the common stack model. + * @param stack - Browser stack text. + * @param resolveScript - Resolver for Client catalog script keys. + * @param skipFrames - Parsed observer frames omitted from the result. + * @returns Parsed call frames, or `undefined` when none remain. + */ +export function parseClientStack( + stack: string | undefined, + resolveScript: ClientScriptKeyResolver, + skipFrames: number, +): RuntimeStackTrace | undefined { + if (stack === undefined) return undefined + const frames: RuntimeCallFrame[] = [] + for (const line of stack.split('\n')) { + const frame = parseFrame(line, resolveScript) + if (frame !== undefined) frames.push(frame) + } + const callFrames = frames.slice(skipFrames) + return callFrames.length === 0 ? undefined : { callFrames } +} + +function parseFrame(line: string, resolveScript: ClientScriptKeyResolver): RuntimeCallFrame | undefined { + const chrome = /^\s*at\s+(?:(.*?)\s+\()?(.+):(\d+):(\d+)\)?$/u.exec(line) + const firefox = chrome === null ? /^(.*?)@(.+):(\d+):(\d+)$/u.exec(line) : null + const match = chrome ?? firefox + if (match === null) return undefined + const url = match[2] + const lineNumber = Number(match[3]) - 1 + const columnNumber = Number(match[4]) - 1 + if (url === undefined || !Number.isSafeInteger(lineNumber) || !Number.isSafeInteger(columnNumber)) return undefined + const scriptKey = resolveScript(url) + return { + functionName: match[1] ?? '', + ...(scriptKey === undefined ? {} : { scriptKey }), + url, + lineNumber, + columnNumber, + } +} diff --git a/packages/experimental/inspector/src/client/index.ts b/packages/experimental/inspector/src/client/index.ts new file mode 100644 index 0000000000..89788b09ce --- /dev/null +++ b/packages/experimental/inspector/src/client/index.ts @@ -0,0 +1,3 @@ +/** Browser Client entry for the experimental Inspector Cordis plugin. */ + +export * from './plugin.ts' diff --git a/packages/experimental/inspector/src/client/inspection/realm.ts b/packages/experimental/inspector/src/client/inspection/realm.ts new file mode 100644 index 0000000000..7271a94ea5 --- /dev/null +++ b/packages/experimental/inspector/src/client/inspection/realm.ts @@ -0,0 +1,37 @@ +/** Stable Client source identity with a fresh descriptor for each WebSocket generation. */ + +import { randomUUID } from '@deepseek-ai/dsh-util-crypto' +import { inspectorId } from '../../shared/identity.ts' +import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts' +import { bridgeCapabilities } from '../cdp/index.ts' + +/** Owns one browser realm's stable source id across transport reconnects. */ +export class ClientRealmSource { + /** Logical source id retained across reconnecting transport generations. */ + readonly sourceId = inspectorId<'InspectorSourceId'>(`client-${randomUUID()}`, 'sourceId') + + constructor(private readonly label: string) {} + + /** + * Create the descriptor for one newly admitted transport generation. + * @param hasSources - Whether the built Client bundle is available for source reads. + * @returns A source descriptor with a fresh generation. + */ + connect(hasSources: boolean): InspectorSourceDescriptor { + return { + sourceId: this.sourceId, + generation: inspectorId<'InspectorSourceGeneration'>(randomUUID(), 'generation'), + kind: 'client', + label: this.label, + timeOriginMs: performance.timeOrigin, + capabilities: bridgeCapabilities(clientOrigin(), hasSources), + } + } +} + +function clientOrigin(): string { + const location = Reflect.get(globalThis, 'location') as unknown + if (typeof location !== 'object' || location === null) return '' + const origin = Reflect.get(location, 'origin') as unknown + return typeof origin === 'string' ? origin : '' +} diff --git a/packages/experimental/inspector/src/client/plugin.ts b/packages/experimental/inspector/src/client/plugin.ts new file mode 100644 index 0000000000..5b619a4788 --- /dev/null +++ b/packages/experimental/inspector/src/client/plugin.ts @@ -0,0 +1,61 @@ +/** Client Cordis plugin that publishes browser observations directly to the Inspector Worker. */ + +import type { Context } from '@deepseek-ai/cordis' +import { parseInspectorClientBootstrap } from '../shared/bridge/control-codec.ts' +import { createInspectorService, type InspectorService as SharedInspectorService } from '../shared/service.ts' +import { publishCordisTree } from './inspection/cordis.ts' +import { startInspectorClient } from './bridge/controller.ts' + +export type { CordisRuntimeTreeReader } from '../shared/cordis/reader.ts' +export type { + CordisRuntimeConnection, + CordisRuntimeContext, + CordisRuntimeFiber, + CordisRuntimeNode, + CordisRuntimeRealm, + CordisRuntimeSource, + CordisRuntimeTree, +} from '../shared/cordis/model.ts' + +/** Client-facing Inspector service backed by the shared implementation. */ +export interface InspectorService extends SharedInspectorService {} + +declare global { + /** Host-injected Inspector Client connection parameters. */ + var __DSH_INSPECTOR__: unknown +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Publish Client-realm observations and query the shared Inspector state. */ + inspector: InspectorService + } +} + +/** Cordis plugin name shared with the Host face. */ +export const name = 'experimental-inspector' + +/** This transport root has no Client service dependencies. */ +export const inject: string[] = [] + +/** Mount the Client source and shared `ctx.inspector` publishing API. */ +export function apply(ctx: Context): void { + const injected = globalThis.__DSH_INSPECTOR__ + if (injected === undefined) { + throw new Error('experimental inspector: Host bootstrap is missing') + } + const bootstrap = parseInspectorClientBootstrap(injected) + ctx.effect(() => { + const source = startInspectorClient(bootstrap) + const disposeCordis = publishCordisTree(ctx, source, { + maxNodes: bootstrap.maxCordisNodes, + maxBytes: bootstrap.maxFrameBytes - 4_096, + }) + const disposeService = ctx.provide('inspector', createInspectorService(source)) + return () => { + disposeService() + disposeCordis() + source.close() + } + }, 'experimental-inspector: Client source') +} diff --git a/packages/experimental/inspector/src/host/bridge/controller.ts b/packages/experimental/inspector/src/host/bridge/controller.ts new file mode 100644 index 0000000000..aa25dac90c --- /dev/null +++ b/packages/experimental/inspector/src/host/bridge/controller.ts @@ -0,0 +1,350 @@ +/** Host controller that owns the Inspector Worker and Host observation source. */ + +import { randomBytes, randomUUID } from 'node:crypto' +import { tmpdir } from 'node:os' +import { MessageChannel, Worker, type MessagePort, type WorkerOptions } from 'node:worker_threads' +import type { InspectorClientBootstrap, InspectorWorkerBoot, InspectorWorkerConfig } from '../../shared/bridge/messages/control.ts' +import { INSPECTOR_PROTOCOL_VERSION } from '../../shared/bridge/version.ts' +import type { InspectorConnection } from '../../shared/bridge/publisher.ts' +import { installFetchObserver, NETWORK_TOPICS, type FetchObserver } from '../inspection/network.ts' +import { HostInspectorSource } from './transport.ts' +import { InspectorWorkerLifecycle } from './lifecycle.ts' + +const DEFAULT_MAX_REQUEST_BODY_BYTES = 8 * 1024 * 1024 +const DEFAULT_MAX_RESPONSE_BODY_BYTES = 32 * 1024 * 1024 +const DEFAULT_MAX_BODY_CHUNK_BYTES = 48 * 1024 +const DEFAULT_MAX_JOURNAL_BYTES = 256 * 1024 * 1024 +const DEFAULT_MAX_RETAINED_REQUESTS = 2_000 +const DEFAULT_MAX_SOURCE_FRAME_BYTES = 128 * 1024 +const DEFAULT_MAX_SOURCE_RECORDS_PER_FRAME = 128 +const DEFAULT_MAX_QUEUED_RECORDS = 2_048 +const DEFAULT_MAX_QUEUED_BYTES = 16 * 1024 * 1024 +const DEFAULT_STARTUP_TIMEOUT_MS = 10_000 +const DEFAULT_STOP_TIMEOUT_MS = 5_000 +const DEFAULT_CLIENT_RECONNECT_BASE_MS = 250 +const DEFAULT_CLIENT_RECONNECT_MAX_MS = 5_000 +const DEFAULT_CLIENT_RUNTIME_TIMEOUT_MS = 30_000 +const DEFAULT_QUERY_TIMEOUT_MS = 10_000 +const DEFAULT_MAX_CLIENT_RUNTIME_OBJECTS = 10_000 +const DEFAULT_MAX_CLIENT_RUNTIME_PROPERTIES = 2_000 +const DEFAULT_MAX_CLIENT_SOURCE_BYTES = 8 * 1024 * 1024 +const DEFAULT_MAX_CORDIS_NODES = 2_048 +const DEFAULT_MAX_DISCONNECTED_CORDIS_TREES = 8 + +/** User-facing Host options; every memory and lifecycle bound is configurable. */ +export interface InspectorOptions { + /** Loopback address used by the Worker HTTP and WebSocket endpoint. */ + readonly host?: '127.0.0.1' + /** First port to bind; occupied ports advance until one is available. */ + readonly port?: number + /** Additional exact browser origins admitted to the Client ingest socket. */ + readonly clientOrigins?: readonly string[] + /** Whether to observe calls made through the current global fetch function. */ + readonly captureFetch?: boolean + /** Maximum request-body prefix retained for one fetch. */ + readonly maxRequestBodyBytes?: number + /** Maximum response-body prefix retained for one fetch. */ + readonly maxResponseBodyBytes?: number + /** Maximum raw bytes encoded into one body observation. */ + readonly maxBodyChunkBytes?: number + /** Maximum total request and response body bytes retained by the Worker. */ + readonly maxJournalBytes?: number + /** Maximum active and completed fetch requests retained by the Worker. */ + readonly maxRetainedRequests?: number + /** Maximum encoded bytes accepted in one source transport frame. */ + readonly maxSourceFrameBytes?: number + /** Maximum observation records accepted in one source batch. */ + readonly maxSourceRecordsPerFrame?: number + /** Maximum records waiting in one producer queue. */ + readonly maxQueuedRecords?: number + /** Maximum encoded bytes waiting in one producer queue. */ + readonly maxQueuedBytes?: number + /** Maximum time allowed for the Worker to become ready. */ + readonly startupTimeoutMs?: number + /** Grace period before a stopping Worker is terminated. */ + readonly stopTimeoutMs?: number + /** Initial upper bound for randomized Client reconnect delay. */ + readonly clientReconnectBaseMs?: number + /** Maximum upper bound for randomized Client reconnect delay. */ + readonly clientReconnectMaxMs?: number + /** Deadline for one Worker-to-Client Runtime or Sources request. */ + readonly clientRuntimeTimeoutMs?: number + /** Deadline for one non-CDP semantic query. */ + readonly queryTimeoutMs?: number + /** Maximum live object handles retained per Client Runtime session. */ + readonly maxClientRuntimeObjects?: number + /** Maximum descriptors returned by one Client property request. */ + readonly maxClientRuntimeProperties?: number + /** Maximum encoded bytes read for one Client script or source map. */ + readonly maxClientSourceBytes?: number + /** Maximum Context and Fiber nodes retained in one realm snapshot. */ + readonly maxCordisNodes?: number + /** Disconnected Cordis snapshots retained after their live realm closes. */ + readonly maxDisconnectedCordisTrees?: number +} + +/** Fully resolved options used by one running Inspector. */ +export interface InspectorSpec { + readonly host: '127.0.0.1' + readonly port: number + readonly clientOrigins: readonly string[] + readonly captureFetch: boolean + readonly maxRequestBodyBytes: number + readonly maxResponseBodyBytes: number + readonly maxBodyChunkBytes: number + readonly maxJournalBytes: number + readonly maxRetainedRequests: number + readonly maxSourceFrameBytes: number + readonly maxSourceRecordsPerFrame: number + readonly maxQueuedRecords: number + readonly maxQueuedBytes: number + readonly startupTimeoutMs: number + readonly stopTimeoutMs: number + readonly clientReconnectBaseMs: number + readonly clientReconnectMaxMs: number + readonly clientRuntimeTimeoutMs: number + readonly queryTimeoutMs: number + readonly maxClientRuntimeObjects: number + readonly maxClientRuntimeProperties: number + readonly maxClientSourceBytes: number + readonly maxCordisNodes: number + readonly maxDisconnectedCordisTrees: number +} + +/** Addresses and browser bootstrap of one bound Worker. */ +export interface InspectorEndpoint { + readonly httpUrl: string + readonly webSocketDebuggerUrl: string + readonly devtoolsFrontendUrl: string + readonly client: InspectorClientBootstrap +} + +/** Running Host-side Inspector owner. */ +export interface InspectorHandle { + readonly endpoint: InspectorEndpoint + readonly source: InspectorConnection + /** Stop capture and wait for the Worker to release every socket and V8 session. */ + close(): Promise +} + +/** + * Resolve and validate all deployment-varying Inspector choices. + * @param options - Partial caller configuration. + * @returns A complete immutable configuration. + */ +export function resolveInspectorOptions(options: InspectorOptions = {}): InspectorSpec { + const spec: InspectorSpec = { + host: options.host ?? '127.0.0.1', + port: natural(options.port ?? 0, 'port', true), + clientOrigins: [...(options.clientOrigins ?? [])], + captureFetch: options.captureFetch ?? true, + maxRequestBodyBytes: natural(options.maxRequestBodyBytes ?? DEFAULT_MAX_REQUEST_BODY_BYTES, 'maxRequestBodyBytes'), + maxResponseBodyBytes: natural(options.maxResponseBodyBytes ?? DEFAULT_MAX_RESPONSE_BODY_BYTES, 'maxResponseBodyBytes'), + maxBodyChunkBytes: natural(options.maxBodyChunkBytes ?? DEFAULT_MAX_BODY_CHUNK_BYTES, 'maxBodyChunkBytes'), + maxJournalBytes: natural(options.maxJournalBytes ?? DEFAULT_MAX_JOURNAL_BYTES, 'maxJournalBytes'), + maxRetainedRequests: natural(options.maxRetainedRequests ?? DEFAULT_MAX_RETAINED_REQUESTS, 'maxRetainedRequests'), + maxSourceFrameBytes: natural(options.maxSourceFrameBytes ?? DEFAULT_MAX_SOURCE_FRAME_BYTES, 'maxSourceFrameBytes'), + maxSourceRecordsPerFrame: natural(options.maxSourceRecordsPerFrame ?? DEFAULT_MAX_SOURCE_RECORDS_PER_FRAME, 'maxSourceRecordsPerFrame'), + maxQueuedRecords: natural(options.maxQueuedRecords ?? DEFAULT_MAX_QUEUED_RECORDS, 'maxQueuedRecords'), + maxQueuedBytes: natural(options.maxQueuedBytes ?? DEFAULT_MAX_QUEUED_BYTES, 'maxQueuedBytes'), + startupTimeoutMs: natural(options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS, 'startupTimeoutMs'), + stopTimeoutMs: natural(options.stopTimeoutMs ?? DEFAULT_STOP_TIMEOUT_MS, 'stopTimeoutMs'), + clientReconnectBaseMs: natural(options.clientReconnectBaseMs ?? DEFAULT_CLIENT_RECONNECT_BASE_MS, 'clientReconnectBaseMs'), + clientReconnectMaxMs: natural(options.clientReconnectMaxMs ?? DEFAULT_CLIENT_RECONNECT_MAX_MS, 'clientReconnectMaxMs'), + clientRuntimeTimeoutMs: natural(options.clientRuntimeTimeoutMs ?? DEFAULT_CLIENT_RUNTIME_TIMEOUT_MS, 'clientRuntimeTimeoutMs'), + queryTimeoutMs: natural(options.queryTimeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS, 'queryTimeoutMs'), + maxClientRuntimeObjects: natural(options.maxClientRuntimeObjects ?? DEFAULT_MAX_CLIENT_RUNTIME_OBJECTS, 'maxClientRuntimeObjects'), + maxClientRuntimeProperties: natural(options.maxClientRuntimeProperties ?? DEFAULT_MAX_CLIENT_RUNTIME_PROPERTIES, 'maxClientRuntimeProperties'), + maxClientSourceBytes: natural(options.maxClientSourceBytes ?? DEFAULT_MAX_CLIENT_SOURCE_BYTES, 'maxClientSourceBytes'), + maxCordisNodes: natural(options.maxCordisNodes ?? DEFAULT_MAX_CORDIS_NODES, 'maxCordisNodes'), + maxDisconnectedCordisTrees: natural( + options.maxDisconnectedCordisTrees ?? DEFAULT_MAX_DISCONNECTED_CORDIS_TREES, + 'maxDisconnectedCordisTrees', + true, + ), + } + if (spec.port > 65_535) throw new Error('inspector: port must not exceed 65535') + const largestEncodedChunk = Math.ceil(spec.maxBodyChunkBytes / 3) * 4 + 4_096 + if (largestEncodedChunk > spec.maxSourceFrameBytes) { + throw new Error('inspector: maxSourceFrameBytes cannot carry one base64 body chunk') + } + if (spec.clientReconnectMaxMs < spec.clientReconnectBaseMs) { + throw new Error('inspector: clientReconnectMaxMs must be at least clientReconnectBaseMs') + } + for (const origin of spec.clientOrigins) { + if (new URL(origin).origin !== origin) throw new Error(`inspector: client origin must be canonical: ${origin}`) + } + return spec +} + +/** + * Start the Worker, create the Host source, and install full fetch capture by default. + * @param options - Partial caller configuration. + * @returns The ready endpoint and its quiescent shutdown handle. + */ +export async function startInspector(options: InspectorOptions = {}): Promise { + const spec = resolveInspectorOptions(options) + const channel = new MessageChannel() + const clientProtocol = `dsh-inspector-v${String(INSPECTOR_PROTOCOL_VERSION)}-${randomBytes(32).toString('base64url')}` + const config: InspectorWorkerConfig = { + host: spec.host, + startPort: spec.port, + targetId: randomUUID(), + clientToken: clientProtocol, + clientOrigins: spec.clientOrigins, + maxSourceFrameBytes: spec.maxSourceFrameBytes, + maxSourceRecordsPerFrame: spec.maxSourceRecordsPerFrame, + maxRetainedRequests: spec.maxRetainedRequests, + maxJournalBytes: spec.maxJournalBytes, + clientRuntimeTimeoutMs: spec.clientRuntimeTimeoutMs, + maxClientSourceBytes: spec.maxClientSourceBytes, + maxCordisNodes: spec.maxCordisNodes, + maxDisconnectedCordisTrees: spec.maxDisconnectedCordisTrees, + } + const boot: InspectorWorkerBoot = { config, hostSourcePort: channel.port2 } + const worker = spawnWorker(boot) + const lifecycle = new InspectorWorkerLifecycle(worker) + let source: HostInspectorSource + try { + source = new HostInspectorSource(channel.port1, { + label: 'Host', + topics: ['*', ...NETWORK_TOPICS], + maxQueuedRecords: spec.maxQueuedRecords, + maxQueuedBytes: spec.maxQueuedBytes, + maxRecordsPerFrame: spec.maxSourceRecordsPerFrame, + maxFrameBytes: spec.maxSourceFrameBytes, + queryTimeoutMs: spec.queryTimeoutMs, + }) + } catch (error) { + channel.port1.close() + await lifecycle.terminate() + throw error + } + + const ready = await lifecycle.waitForReady(spec.startupTimeoutMs).catch(async (error: unknown) => { + source.close() + await lifecycle.terminate() + throw error + }) + const authority = `${ready.host}:${String(ready.port)}` + const endpoint: InspectorEndpoint = { + httpUrl: `http://${authority}/`, + webSocketDebuggerUrl: `ws://${authority}/devtools/page/${ready.targetId}`, + devtoolsFrontendUrl: `devtools://devtools/bundled/devtools_app.html?ws=${authority}/devtools/page/${ready.targetId}&panel=elements&noJavaScriptCompletion=true`, + client: { + endpoint: `ws://${authority}/ingest`, + protocol: clientProtocol, + maxQueuedRecords: spec.maxQueuedRecords, + maxQueuedBytes: spec.maxQueuedBytes, + maxRecordsPerFrame: spec.maxSourceRecordsPerFrame, + maxFrameBytes: spec.maxSourceFrameBytes, + reconnectBaseMs: spec.clientReconnectBaseMs, + reconnectMaxMs: spec.clientReconnectMaxMs, + queryTimeoutMs: spec.queryTimeoutMs, + maxRuntimeObjectsPerSession: spec.maxClientRuntimeObjects, + maxRuntimePropertiesPerResult: spec.maxClientRuntimeProperties, + maxClientSourceBytes: spec.maxClientSourceBytes, + maxCordisNodes: spec.maxCordisNodes, + }, + } + let fetchObserver: FetchObserver | undefined + try { + fetchObserver = spec.captureFetch + ? installFetchObserver(source, { + maxRequestBodyBytes: spec.maxRequestBodyBytes, + maxResponseBodyBytes: spec.maxResponseBodyBytes, + maxChunkBytes: spec.maxBodyChunkBytes, + }) + : undefined + } catch (error) { + source.close() + await lifecycle.terminate() + throw error + } + + lifecycle.markRunning((error) => { + try { + source.close() + } catch (closeError) { + console.error('dsh inspector: Host source cleanup after Worker failure failed', closeError) + } + void fetchObserver?.stop().catch((stopError: unknown) => { + console.error('dsh inspector: fetch cleanup after Worker failure failed', stopError) + }) + console.error('dsh inspector: Worker stopped unexpectedly', error) + }) + + let closing: Promise | undefined + return { + endpoint, + source, + close(): Promise { + closing ??= closeInspector(lifecycle, source, fetchObserver, spec.stopTimeoutMs) + return closing + }, + } +} + +function spawnWorker(boot: InspectorWorkerBoot): Worker { + const options: WorkerOptions = { + workerData: boot, + transferList: [boot.hostSourcePort], + execArgv: [], + } + if (!import.meta.url.endsWith('.ts')) { + return new Worker(new URL('./worker.js', import.meta.url), options) + } + const workerEntry = new URL('../../worker/entry.ts', import.meta.url) + const tsxEsmApiEntry = import.meta.resolve('tsx/esm/api') + const bootstrap = [ + `import { register } from ${JSON.stringify(tsxEsmApiEntry)}`, + 'register()', + `await import(${JSON.stringify(workerEntry.href)})`, + ].join('\n') + return new Worker(new URL(`data:text/javascript,${encodeURIComponent(bootstrap)}`), { + ...options, + env: sourceWorkerEnv(), + }) +} + +function sourceWorkerEnv(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {} + if (process.platform === 'win32') { + env.TMP = tmpdir() + env.TEMP = tmpdir() + } + if (process.env.TSX_TSCONFIG_PATH !== undefined) env.TSX_TSCONFIG_PATH = process.env.TSX_TSCONFIG_PATH + return env +} + +async function closeInspector( + lifecycle: InspectorWorkerLifecycle, + source: HostInspectorSource, + fetchObserver: FetchObserver | undefined, + timeoutMs: number, +): Promise { + const failures: unknown[] = [] + try { + await fetchObserver?.stop() + } catch (error) { + failures.push(error) + } + try { + source.close() + } catch (error) { + failures.push(error) + } + try { + await lifecycle.stop(timeoutMs) + } catch (error) { + failures.push(error) + } + if (failures.length > 0) throw new AggregateError(failures, 'inspector: shutdown failed') +} + +function natural(value: number, name: string, zero = false): number { + if (!Number.isSafeInteger(value) || value < (zero ? 0 : 1)) { + throw new Error(`inspector: ${name} must be ${zero ? 'a non-negative' : 'a positive'} safe integer`) + } + return value +} diff --git a/packages/experimental/inspector/src/host/bridge/dispatcher.ts b/packages/experimental/inspector/src/host/bridge/dispatcher.ts new file mode 100644 index 0000000000..441dcb1512 --- /dev/null +++ b/packages/experimental/inspector/src/host/bridge/dispatcher.ts @@ -0,0 +1,48 @@ +/** Dispatch of validated Worker frames accepted by the Host MessagePort. */ + +import type { SourceAcceptedFrame, SourceRejectedFrame, SourceResnapshotFrame, WorkerToSourceFrame } from '../../shared/bridge/messages/observation.ts' +import { rejectConsoleBridgeCommand } from '../cdp/console.ts' +import { rejectRuntimeBridgeCommand } from '../cdp/runtime.ts' +import { rejectSourcesBridgeCommand } from '../cdp/sources.ts' + +/** Operations invoked for source-lifecycle frames addressed to the Host. */ +export interface HostBridgeFrameHandlers { + accepted(frame: SourceAcceptedFrame): void + resnapshot(frame: SourceResnapshotFrame): void + rejected(frame: SourceRejectedFrame): void +} + +/** + * Dispatch one validated Worker frame and reject Client-only commands on the Host carrier. + * @param frame - Decoded Worker-to-source frame. + * @param handlers - Host source-lifecycle operations. + */ +export function dispatchBridgeFrame(frame: WorkerToSourceFrame, handlers: HostBridgeFrameHandlers): void { + switch (frame.t) { + case 'source/accepted': + handlers.accepted(frame) + return + case 'source/resnapshot': + handlers.resnapshot(frame) + return + case 'source/rejected': + handlers.rejected(frame) + return + case 'client-runtime/request': + return rejectRuntimeBridgeCommand(frame.command) + case 'client-console/enable': + case 'client-console/disable': + return rejectConsoleBridgeCommand(frame.t) + case 'client-sources/request': + return rejectSourcesBridgeCommand() + case 'client-runtime/session-closed': + case 'client-sources/session-closed': + return + default: + return assertNever(frame) + } +} + +function assertNever(value: never): never { + throw new Error(`Unexpected Worker source frame: ${JSON.stringify(value)}`) +} diff --git a/packages/experimental/inspector/src/host/bridge/lifecycle.ts b/packages/experimental/inspector/src/host/bridge/lifecycle.ts new file mode 100644 index 0000000000..7ff5bd4328 --- /dev/null +++ b/packages/experimental/inspector/src/host/bridge/lifecycle.ts @@ -0,0 +1,125 @@ +/** Failure containment and shutdown coordination for the Inspector Worker. */ + +import type { Worker } from 'node:worker_threads' +import type { InspectorHostControl, InspectorWorkerControl } from '../../shared/bridge/messages/control.ts' +import { parseInspectorWorkerControl } from '../../shared/bridge/control-codec.ts' + +/** Tracks Worker termination without removing the listener that contains runtime errors. */ +export class InspectorWorkerLifecycle { + private readonly exitResolution = Promise.withResolvers() + private readonly failureResolution = Promise.withResolvers() + private failure: Error | undefined + private running = false + private expectedExit = false + private notified = false + private onUnexpectedExit: ((error: Error) => void) | undefined + private exitCodeValue: number | undefined + + /** Worker exit code once its `exit` event has fired. */ + get exitCode(): number | undefined { + return this.exitCodeValue + } + + constructor(private readonly worker: Worker) { + worker.on('error', (error) => { + this.failure ??= error + this.failureResolution.resolve(error) + this.notifyUnexpectedExit() + }) + worker.once('exit', (code) => { + this.exitCodeValue = code + this.exitResolution.resolve(code) + this.notifyUnexpectedExit() + }) + } + + /** + * Wait for the validated ready frame while also observing startup failure and exit. + * @param timeoutMs - Readiness deadline in milliseconds. + * @returns The Worker's bound endpoint fields. + */ + async waitForReady(timeoutMs: number): Promise> { + let timer: NodeJS.Timeout | undefined + let onMessage: ((value: unknown) => void) | undefined + const message = new Promise>((resolve, reject) => { + onMessage = (value: unknown): void => { + let control: InspectorWorkerControl + try { + control = parseInspectorWorkerControl(value) + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))) + return + } + if (control.type === 'ready') resolve(control) + else if (control.type === 'failure') reject(new Error(`inspector Worker failed: ${control.message}`)) + } + timer = setTimeout(() => { + reject(new Error(`inspector Worker did not become ready within ${String(timeoutMs)}ms`)) + }, timeoutMs) + this.worker.on('message', onMessage) + }) + try { + return await Promise.race([ + message, + this.failureResolution.promise.then((error) => { throw error }), + this.exitResolution.promise.then((code) => { + throw new Error(`inspector Worker exited before readiness (code ${String(code)})`) + }), + ]) + } finally { + if (timer !== undefined) clearTimeout(timer) + if (onMessage !== undefined) this.worker.off('message', onMessage) + } + } + + /** + * Begin reporting an unexpected runtime exit through one contained callback. + * @param listener - Failure observer that must not throw. + */ + markRunning(listener: (error: Error) => void): void { + this.running = true + this.onUnexpectedExit = listener + this.notifyUnexpectedExit() + } + + /** Mark subsequent Worker termination as owner-requested. */ + expectExit(): void { + this.expectedExit = true + } + + /** Terminate the Worker during failed initialization. */ + async terminate(): Promise { + this.expectExit() + if (this.exitCodeValue === undefined) await this.worker.terminate() + } + + /** + * Request graceful shutdown and terminate after the deadline. + * @param timeoutMs - Grace period before forced termination. + */ + async stop(timeoutMs: number): Promise { + this.expectExit() + if (this.exitCodeValue !== undefined) return + this.worker.postMessage({ type: 'shutdown' } satisfies InspectorHostControl) + let timer: NodeJS.Timeout | undefined + const timeout = new Promise<'timeout'>((resolve) => { + timer = setTimeout(() => { resolve('timeout') }, timeoutMs) + }) + const outcome = await Promise.race([ + this.exitResolution.promise.then(() => 'exited' as const), + timeout, + ]) + if (timer !== undefined) clearTimeout(timer) + if (outcome === 'exited') return + await this.worker.terminate() + throw new Error(`inspector Worker did not stop within ${String(timeoutMs)}ms and was terminated`) + } + + private notifyUnexpectedExit(): void { + if (!this.running || this.expectedExit || this.notified || this.exitCodeValue === undefined) return + this.notified = true + this.onUnexpectedExit?.(this.failure ?? new Error( + `inspector Worker exited unexpectedly with code ${String(this.exitCodeValue)}`, + )) + } +} diff --git a/packages/experimental/inspector/src/host/bridge/publisher.ts b/packages/experimental/inspector/src/host/bridge/publisher.ts new file mode 100644 index 0000000000..89564261da --- /dev/null +++ b/packages/experimental/inspector/src/host/bridge/publisher.ts @@ -0,0 +1,64 @@ +/** Buffered Host observation publication over a dedicated Worker MessagePort. */ + +import type { MessagePort } from 'node:worker_threads' +import { InspectorSourceBuffer, type InspectorSourceBufferOptions } from '../../shared/bridge/buffer.ts' +import type { InspectorJsonValue } from '../../shared/json.ts' +import type { InspectorStatePublisher } from '../../shared/bridge/publisher.ts' +import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts' + +/** Non-blocking Host publisher with microtask-coalesced MessagePort writes. */ +export class HostBridgePublisher implements InspectorStatePublisher { + private readonly records: InspectorSourceBuffer + private flushScheduled = false + private closed = false + + constructor( + private readonly port: MessagePort, + private readonly source: InspectorSourceDescriptor, + options: InspectorSourceBufferOptions, + ) { + this.records = new InspectorSourceBuffer(options) + } + + publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()): void { + if (this.closed) return + this.records.publish(topic, payload, monotonicMs) + this.scheduleFlush() + } + + setState(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()): void { + if (this.closed) throw new Error('inspector: Host source is closed') + this.records.setState(topic, payload, monotonicMs) + this.scheduleFlush() + } + + /** Send the retained state as a complete source replacement. */ + replace(): void { + this.port.postMessage(this.records.replacement(this.source.sourceId, this.source.generation)) + } + + /** Flush every currently queued observation batch. */ + flush(): void { + let frame = this.records.takeBatch(this.source.sourceId, this.source.generation) + while (frame !== undefined) { + this.port.postMessage(frame) + frame = this.records.takeBatch(this.source.sourceId, this.source.generation) + } + } + + /** Flush pending observations and reject later publication. */ + close(): void { + if (this.closed) return + this.flush() + this.closed = true + } + + private scheduleFlush(): void { + if (!this.records.hasPending || this.flushScheduled) return + this.flushScheduled = true + queueMicrotask(() => { + this.flushScheduled = false + this.flush() + }) + } +} diff --git a/packages/experimental/inspector/src/host/bridge/rpc.ts b/packages/experimental/inspector/src/host/bridge/rpc.ts new file mode 100644 index 0000000000..cf3429068b --- /dev/null +++ b/packages/experimental/inspector/src/host/bridge/rpc.ts @@ -0,0 +1,59 @@ +/** Host-side non-CDP query bridge over the Worker MessagePort. */ + +import type { MessagePort } from 'node:worker_threads' +import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts' +import type { InspectorQuery, InspectorQueryResultFor } from '../../shared/bridge/messages/query/commands.ts' +import { InspectorQueryConnection, type InspectorQueryConnectionOptions } from '../../shared/bridge/rpc.ts' + +/** Owns query correlation for one Host source generation. */ +export class HostBridgeRpc { + private readonly connection: InspectorQueryConnection + + constructor(private readonly port: MessagePort, options: InspectorQueryConnectionOptions) { + this.connection = new InspectorQueryConnection(options) + } + + /** + * Connect query writes after the Worker accepts the Host source. + * @param source - Accepted Host source descriptor. + */ + connect(source: InspectorSourceDescriptor): void { + this.connection.connect(source.sourceId, source.generation, { + send: (frame) => { this.port.postMessage(frame) }, + }) + } + + /** + * Consume a potential query response. + * @param value - Decoded Worker message. + * @returns Whether the message belonged to this RPC protocol. + */ + receive(value: unknown): boolean { + return this.connection.receive(value) + } + + /** + * Execute one non-CDP query through the active Host generation. + * @param query - Typed query operation. + * @returns Its correlated typed result. + */ + request(query: Query): Promise> { + return this.connection.request(query) + } + + /** + * Reject pending requests while retaining the reusable Host bridge. + * @param reason - Failure reported to pending callers. + */ + disconnect(reason: string): void { + this.connection.disconnect(reason) + } + + /** + * Permanently reject all current and future requests. + * @param reason - Failure reported to pending callers. + */ + close(reason: string): void { + this.connection.close(reason) + } +} diff --git a/packages/experimental/inspector/src/host/bridge/transport.ts b/packages/experimental/inspector/src/host/bridge/transport.ts new file mode 100644 index 0000000000..b2eac495d5 --- /dev/null +++ b/packages/experimental/inspector/src/host/bridge/transport.ts @@ -0,0 +1,106 @@ +/** Host-realm observation publisher over a dedicated MessagePort. */ + +import type { MessagePort } from 'node:worker_threads' +import type { InspectorQuery, InspectorQueryResultFor } from '../../shared/bridge/messages/query/commands.ts' +import { + INSPECTOR_PROTOCOL_VERSION, + parseWorkerSourceFrame, + type SourceCloseFrame, + type SourceOpenFrame, + type WorkerToSourceFrame, +} from '../../shared/bridge/messages/observation.ts' +import type { InspectorConnection } from '../../shared/bridge/publisher.ts' +import { createHostRealmSource } from '../inspection/realm.ts' +import { HostBridgePublisher } from './publisher.ts' +import { HostBridgeRpc } from './rpc.ts' +import type { InspectorJsonValue } from '../../shared/json.ts' +import { dispatchBridgeFrame } from './dispatcher.ts' + +/** Buffer limits for one source publisher. */ +export interface HostSourceOptions { + readonly label: string + readonly topics: readonly string[] + readonly maxQueuedRecords: number + readonly maxQueuedBytes: number + readonly maxRecordsPerFrame: number + readonly maxFrameBytes: number + readonly queryTimeoutMs: number +} + +/** Non-blocking Host source; queue overflow is represented by `droppedBefore` on the next batch. */ +export class HostInspectorSource implements InspectorConnection { + private readonly source + private readonly publisher: HostBridgePublisher + private closed = false + private readonly queries: HostBridgeRpc + + constructor(private readonly port: MessagePort, options: HostSourceOptions) { + this.source = createHostRealmSource(options.label) + this.publisher = new HostBridgePublisher(port, this.source, options) + this.queries = new HostBridgeRpc(port, { + timeoutMs: options.queryTimeoutMs, + maxFrameBytes: options.maxFrameBytes, + }) + port.on('message', (value: unknown) => { + try { + if (this.queries.receive(value)) return + this.receive(parseWorkerSourceFrame(value)) + } catch { + this.close() + } + }) + port.on('close', () => { this.queries.disconnect('Inspector Host source disconnected') }) + port.start() + const open: SourceOpenFrame = { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'source/open', + source: this.source, + topics: [...options.topics], + } + port.postMessage(open) + this.publisher.replace() + } + + /** Publish one observation without waiting on Worker processing. */ + publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()): void { + if (this.closed) return + this.publisher.publish(topic, payload, monotonicMs) + } + + /** Retain and publish one state value for future `source/replace` frames. */ + setState(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()): void { + if (this.closed) throw new Error('inspector: Host source is closed') + this.publisher.setState(topic, payload, monotonicMs) + } + + /** Execute one non-CDP query through the accepted Host source generation. */ + request(query: Query): Promise> { + return this.queries.request(query) + } + + /** Flush pending observations and close the source port. */ + close(): void { + if (this.closed) return + this.publisher.close() + this.closed = true + this.queries.close('Inspector Host source closed') + const frame: SourceCloseFrame = { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'source/close', + sourceId: this.source.sourceId, + generation: this.source.generation, + } + this.port.postMessage(frame) + this.port.close() + } + + private receive(frame: WorkerToSourceFrame): void { + if (frame.t !== 'source/rejected' + && (frame.sourceId !== this.source.sourceId || frame.generation !== this.source.generation)) return + dispatchBridgeFrame(frame, { + accepted: () => { this.queries.connect(this.source) }, + resnapshot: () => { this.publisher.replace() }, + rejected: (rejected) => { this.queries.disconnect(`Inspector Host source rejected: ${rejected.message}`) }, + }) + } +} diff --git a/packages/experimental/inspector/src/host/cdp/console.ts b/packages/experimental/inspector/src/host/cdp/console.ts new file mode 100644 index 0000000000..343913d367 --- /dev/null +++ b/packages/experimental/inspector/src/host/cdp/console.ts @@ -0,0 +1,20 @@ +/** Host Console is served directly by the Worker-side Node inspector adapter. */ + +import type { InspectorSourceCapability } from '../../shared/bridge/messages/observation.ts' + +/** + * Describe Host Console transport ownership. + * @returns No Host-main-thread Console bridge capability. + */ +export function consoleBridgeCapability(): InspectorSourceCapability | undefined { + return undefined +} + +/** + * Reject a Client Console control frame that was routed to the Host source. + * @param operation - Misrouted Console frame type. + * @returns This function never returns. + */ +export function rejectConsoleBridgeCommand(operation: string): never { + throw new Error(`inspector protocol: ${operation} cannot use the Host source bridge`) +} diff --git a/packages/experimental/inspector/src/host/cdp/debugger.ts b/packages/experimental/inspector/src/host/cdp/debugger.ts new file mode 100644 index 0000000000..e464ca4371 --- /dev/null +++ b/packages/experimental/inspector/src/host/cdp/debugger.ts @@ -0,0 +1,11 @@ +/** Host debugging is served directly by the Worker-side Node inspector adapter. */ + +import type { InspectorSourceCapability } from '../../shared/bridge/messages/observation.ts' + +/** + * Describe Host debugger transport ownership. + * @returns No Host-main-thread Debugger bridge capability. + */ +export function debuggerBridgeCapability(): InspectorSourceCapability | undefined { + return undefined +} diff --git a/packages/experimental/inspector/src/host/cdp/errors.ts b/packages/experimental/inspector/src/host/cdp/errors.ts new file mode 100644 index 0000000000..011354dca3 --- /dev/null +++ b/packages/experimental/inspector/src/host/cdp/errors.ts @@ -0,0 +1,10 @@ +/** Explicit failure for Client-style CDP bridge commands misrouted to the Host. */ + +import { HOST_CDP_BRIDGE_REASON } from './stack.ts' + +/** Host Runtime uses the Worker-side Node inspector session instead of source RPC. */ +export class HostCdpBridgeUnavailableError extends Error { + constructor(operation: string) { + super(`inspector protocol: ${operation} cannot use the Host source bridge; ${HOST_CDP_BRIDGE_REASON}`) + } +} diff --git a/packages/experimental/inspector/src/host/cdp/heap-profiler.ts b/packages/experimental/inspector/src/host/cdp/heap-profiler.ts new file mode 100644 index 0000000000..bd2c1ed8b6 --- /dev/null +++ b/packages/experimental/inspector/src/host/cdp/heap-profiler.ts @@ -0,0 +1,11 @@ +/** Host heap profiling is served directly by the Worker-side Node inspector adapter. */ + +import type { InspectorSourceCapability } from '../../shared/bridge/messages/observation.ts' + +/** + * Describe Host heap profiler transport ownership. + * @returns No Host-main-thread HeapProfiler bridge capability. + */ +export function heapProfilerBridgeCapability(): InspectorSourceCapability | undefined { + return undefined +} diff --git a/packages/experimental/inspector/src/host/cdp/index.ts b/packages/experimental/inspector/src/host/cdp/index.ts new file mode 100644 index 0000000000..de7fb195c6 --- /dev/null +++ b/packages/experimental/inspector/src/host/cdp/index.ts @@ -0,0 +1,26 @@ +/** Source-side CDP capability declarations for the Host realm. */ + +import type { InspectorSourceCapability } from '../../shared/bridge/messages/observation.ts' +import { consoleBridgeCapability } from './console.ts' +import { debuggerBridgeCapability } from './debugger.ts' +import { heapProfilerBridgeCapability } from './heap-profiler.ts' +import { profilerBridgeCapability } from './profiler.ts' +import { runtimeBridgeCapability } from './runtime.ts' +import { sourcesBridgeCapability } from './sources.ts' + +/** + * Collect Host source-bridge capabilities. + * @param origin - Unused Host origin supplied for parity with the Client adapter. + * @param hasSources - Unused source availability supplied for parity with the Client adapter. + * @returns No capabilities because the Worker attaches to Host V8 directly. + */ +export function bridgeCapabilities(origin: string, hasSources: boolean): readonly InspectorSourceCapability[] { + return [ + runtimeBridgeCapability(origin), + consoleBridgeCapability(), + sourcesBridgeCapability(hasSources), + debuggerBridgeCapability(), + profilerBridgeCapability(), + heapProfilerBridgeCapability(), + ].filter((capability): capability is InspectorSourceCapability => capability !== undefined) +} diff --git a/packages/experimental/inspector/src/host/cdp/objects.ts b/packages/experimental/inspector/src/host/cdp/objects.ts new file mode 100644 index 0000000000..dffce7b35b --- /dev/null +++ b/packages/experimental/inspector/src/host/cdp/objects.ts @@ -0,0 +1,12 @@ +/** Host RemoteObject handles never cross the Host source bridge. */ + +import { HostCdpBridgeUnavailableError } from './errors.ts' + +/** + * Reject an object operation that must use the Worker-owned native inspector session. + * @param operation - Misrouted object operation. + * @returns This function never returns. + */ +export function rejectObjectBridgeOperation(operation: string): never { + throw new HostCdpBridgeUnavailableError(operation) +} diff --git a/packages/experimental/inspector/src/host/cdp/profiler.ts b/packages/experimental/inspector/src/host/cdp/profiler.ts new file mode 100644 index 0000000000..76fc681403 --- /dev/null +++ b/packages/experimental/inspector/src/host/cdp/profiler.ts @@ -0,0 +1,11 @@ +/** Host CPU profiling is served directly by the Worker-side Node inspector adapter. */ + +import type { InspectorSourceCapability } from '../../shared/bridge/messages/observation.ts' + +/** + * Describe Host CPU profiler transport ownership. + * @returns No Host-main-thread Profiler bridge capability. + */ +export function profilerBridgeCapability(): InspectorSourceCapability | undefined { + return undefined +} diff --git a/packages/experimental/inspector/src/host/cdp/properties.ts b/packages/experimental/inspector/src/host/cdp/properties.ts new file mode 100644 index 0000000000..e28c8c4d6e --- /dev/null +++ b/packages/experimental/inspector/src/host/cdp/properties.ts @@ -0,0 +1,11 @@ +/** Host property enumeration never crosses the Host source bridge. */ + +import { rejectObjectBridgeOperation } from './objects.ts' + +/** + * Reject a property request that must use the Worker-owned native inspector session. + * @returns This function never returns. + */ +export function rejectPropertyBridgeOperation(): never { + return rejectObjectBridgeOperation('client-runtime/get-properties') +} diff --git a/packages/experimental/inspector/src/host/cdp/runtime.ts b/packages/experimental/inspector/src/host/cdp/runtime.ts new file mode 100644 index 0000000000..8e7484d616 --- /dev/null +++ b/packages/experimental/inspector/src/host/cdp/runtime.ts @@ -0,0 +1,42 @@ +/** Host Runtime is served directly by the Worker-side Node inspector adapter. */ + +import type { ClientRuntimeCommand } from '../../shared/bridge/messages/runtime/index.ts' +import type { InspectorSourceCapability } from '../../shared/bridge/messages/observation.ts' +import { HostCdpBridgeUnavailableError } from './errors.ts' +import { rejectObjectBridgeOperation } from './objects.ts' +import { rejectPropertyBridgeOperation } from './properties.ts' + +/** + * Describe Host Runtime transport ownership. + * @param _origin - Ignored because Host Runtime does not cross the source bridge. + * @returns No Host-main-thread Runtime bridge capability. + */ +export function runtimeBridgeCapability(_origin: string): InspectorSourceCapability | undefined { + return undefined +} + +/** + * Reject a Client Runtime command that was routed to the Host source. + * @param command - Misrouted Client Runtime operation. + * @returns This function never returns. + */ +export function rejectRuntimeBridgeCommand(command: ClientRuntimeCommand): never { + switch (command.op) { + case 'get-properties': + return rejectPropertyBridgeOperation() + case 'release-object': + case 'release-object-group': + return rejectObjectBridgeOperation(`client-runtime/${command.op}`) + case 'evaluate': + case 'call-function': + case 'await-promise': + case 'global-lexical-scope-names': + throw new HostCdpBridgeUnavailableError(`client-runtime/${command.op}`) + default: + return assertNever(command) + } +} + +function assertNever(value: never): never { + throw new Error(`Unexpected Host Runtime bridge command: ${JSON.stringify(value)}`) +} diff --git a/packages/experimental/inspector/src/host/cdp/sources.ts b/packages/experimental/inspector/src/host/cdp/sources.ts new file mode 100644 index 0000000000..7b93937bad --- /dev/null +++ b/packages/experimental/inspector/src/host/cdp/sources.ts @@ -0,0 +1,20 @@ +/** Host Sources are served directly by the Worker-side Node inspector adapter. */ + +import type { InspectorSourceCapability } from '../../shared/bridge/messages/observation.ts' + +/** + * Describe Host Sources transport ownership. + * @param _available - Ignored because Host Sources do not cross the source bridge. + * @returns No Host-main-thread Sources bridge capability. + */ +export function sourcesBridgeCapability(_available: boolean): InspectorSourceCapability | undefined { + return undefined +} + +/** + * Reject a Client Sources request that was routed to the Host source. + * @returns This function never returns. + */ +export function rejectSourcesBridgeCommand(): never { + throw new Error('inspector protocol: Client Sources cannot use the Host source bridge') +} diff --git a/packages/experimental/inspector/src/host/cdp/stack.ts b/packages/experimental/inspector/src/host/cdp/stack.ts new file mode 100644 index 0000000000..c633ea0041 --- /dev/null +++ b/packages/experimental/inspector/src/host/cdp/stack.ts @@ -0,0 +1,4 @@ +/** Host stack and call-frame data remain owned by the Worker-side Node inspector session. */ + +/** Stable explanation used for Host bridge rejections. */ +export const HOST_CDP_BRIDGE_REASON = 'Host Runtime is attached directly from the Inspector Worker' diff --git a/packages/experimental/inspector/src/host/index.ts b/packages/experimental/inspector/src/host/index.ts new file mode 100644 index 0000000000..0af3db1fae --- /dev/null +++ b/packages/experimental/inspector/src/host/index.ts @@ -0,0 +1,3 @@ +/** Host entry for the experimental Inspector Cordis plugin and library API. */ + +export * from './plugin.ts' diff --git a/packages/experimental/inspector/src/host/inspection/realm.ts b/packages/experimental/inspector/src/host/inspection/realm.ts new file mode 100644 index 0000000000..ca4b7329e6 --- /dev/null +++ b/packages/experimental/inspector/src/host/inspection/realm.ts @@ -0,0 +1,22 @@ +/** Stable descriptor for the Host observation source generation. */ + +import { randomUUID } from 'node:crypto' +import { inspectorId } from '../../shared/identity.ts' +import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts' +import { bridgeCapabilities } from '../cdp/index.ts' + +/** + * Create the descriptor for one Host-to-Worker MessagePort generation. + * @param label - Human-readable Host execution-context label. + * @returns The complete Host source descriptor. + */ +export function createHostRealmSource(label: string): InspectorSourceDescriptor { + return { + sourceId: inspectorId<'InspectorSourceId'>(`host-${randomUUID()}`, 'sourceId'), + generation: inspectorId<'InspectorSourceGeneration'>(randomUUID(), 'generation'), + kind: 'host', + label, + timeOriginMs: performance.timeOrigin, + capabilities: bridgeCapabilities('', false), + } +} diff --git a/packages/experimental/inspector/src/host/plugin.ts b/packages/experimental/inspector/src/host/plugin.ts new file mode 100644 index 0000000000..039eb1b8ca --- /dev/null +++ b/packages/experimental/inspector/src/host/plugin.ts @@ -0,0 +1,81 @@ +/** Host Cordis plugin for the cross-realm Inspector Worker and full fetch capture. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' +import { resolveInspectorOptions, startInspector, type InspectorOptions } from './bridge/controller.ts' +import { createInspectorService } from '../shared/service.ts' +import { publishCordisTree } from './inspection/cordis.ts' + +export { resolveInspectorOptions, startInspector } from './bridge/controller.ts' +export type { InspectorEndpoint, InspectorHandle, InspectorOptions, InspectorSpec } from './bridge/controller.ts' +export type { CordisRuntimeTreeReader } from '../shared/cordis/reader.ts' +export type { + CordisRuntimeConnection, + CordisRuntimeContext, + CordisRuntimeFiber, + CordisRuntimeNode, + CordisRuntimeRealm, + CordisRuntimeSource, + CordisRuntimeTree, +} from '../shared/cordis/model.ts' +export type { InspectorClientBootstrap } from '../shared/bridge/messages/control.ts' +export type { InspectorRecordInput, InspectorSourceDescriptor, InspectorSourceKind } from '../shared/bridge/messages/observation.ts' +export type { InspectorJsonObject, InspectorJsonPrimitive, InspectorJsonValue } from '../shared/json.ts' +export type { + CordisContextTreeNode, + CordisFiberTreeNode, + CordisTreeNode, + CordisTreeSnapshot, +} from '../shared/cordis/snapshot.ts' + +/** Configuration consumed by the Host implementation after package-entry validation. */ +export interface HostPluginConfig extends Omit { + /** Browser origins allowed to open the Client ingest WebSocket. */ + clientOrigins?: string[] +} + +/** Start the Worker, expose `ctx.inspector`, and inject the matching Client bootstrap. */ +export async function apply(ctx: Context, config: HostPluginConfig): Promise { + await ctx.effect(async () => { + const spec = resolveInspectorOptions(config) + const handle = await startInspector(spec) + const disposers: Array<() => unknown> = [] + try { + disposers.push(publishCordisTree(ctx, handle.source, { + maxNodes: spec.maxCordisNodes, + maxBytes: spec.maxSourceFrameBytes - 4_096, + })) + disposers.push(ctx.provide('inspector', createInspectorService(handle.source))) + disposers.push(ctx.on('webserver/index-inject', (table: IndexInjection[]) => { + table.push({ kind: 'global', name: '__DSH_INSPECTOR__', value: handle.endpoint.client }) + })) + console.log(`dsh inspector: ${handle.endpoint.devtoolsFrontendUrl}`) + } catch (error) { + await disposeInspector(handle, disposers).catch((cleanupError: unknown) => { + ctx.logger.error('experimental-inspector: initialization rollback failed', cleanupError) + }) + throw error + } + return async () => { await disposeInspector(handle, disposers) } + }, 'experimental-inspector: Host Worker') +} + +async function disposeInspector( + handle: Awaited>, + disposers: readonly (() => unknown)[], +): Promise { + const failures: unknown[] = [] + for (const dispose of [...disposers].reverse()) { + try { + await dispose() + } catch (error) { + failures.push(error) + } + } + try { + await handle.close() + } catch (error) { + failures.push(error) + } + if (failures.length > 0) throw new AggregateError(failures, 'experimental-inspector: disposal failed') +} diff --git a/packages/experimental/inspector/src/index.ts b/packages/experimental/inspector/src/index.ts new file mode 100644 index 0000000000..9617d50a10 --- /dev/null +++ b/packages/experimental/inspector/src/index.ts @@ -0,0 +1,108 @@ +/** Repository-facing Host package entry over the mirrored implementation tree. */ + +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import { + apply as applyHost, +} from './host/plugin.ts' +import { resolveInspectorOptions, type InspectorOptions } from './host/bridge/controller.ts' +import type { CordisRuntimeTreeReader } from './shared/cordis/reader.ts' +import type { InspectorJsonValue } from './shared/json.ts' + +export { resolveInspectorOptions, startInspector } from './host/plugin.ts' +export type { InspectorEndpoint, InspectorHandle, InspectorOptions, InspectorSpec } from './host/plugin.ts' +export type { CordisRuntimeTreeReader } from './shared/cordis/reader.ts' +export type { + CordisRuntimeConnection, + CordisRuntimeContext, + CordisRuntimeFiber, + CordisRuntimeNode, + CordisRuntimeRealm, + CordisRuntimeSource, + CordisRuntimeTree, +} from './shared/cordis/model.ts' +export type { InspectorClientBootstrap } from './shared/bridge/messages/control.ts' +export type { + InspectorRecordInput, + InspectorSourceDescriptor, + InspectorSourceKind, +} from './shared/bridge/messages/observation.ts' +export type { InspectorJsonObject, InspectorJsonPrimitive, InspectorJsonValue } from './shared/json.ts' +export type { + CordisContextTreeNode, + CordisFiberTreeNode, + CordisTreeNode, + CordisTreeSnapshot, +} from './shared/cordis/snapshot.ts' + +/** Shared Host/Client service façade over the realm's source publisher. */ +export interface InspectorService { + /** + * Publish one JSON observation without waiting for Worker delivery. + * @param topic - Domain-owned topic name. + * @param payload - JSON value validated before it reaches the carrier. + * @param monotonicMs - Source-clock timestamp; defaults to `performance.now()`. + */ + publish(topic: string, payload: InspectorJsonValue, monotonicMs?: number): void + + /** Read-only Cordis topology queries independent of CDP sessions. */ + readonly cordis: CordisRuntimeTreeReader +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Publish Host-realm observations and query the shared Inspector state. */ + inspector: InspectorService + } +} + +/** Cordis plugin name shared with the Client face. */ +export const name = 'experimental-inspector' + +/** Host service required to inject the Client connection bootstrap into index.html. */ +export const inject = ['webServer'] + +/** Host plugin configuration. Fetch capture is enabled by default. */ +export interface Config extends Omit { + /** Browser origins allowed to open the Client ingest WebSocket. */ + clientOrigins?: string[] +} + +const libraryDefaults = resolveInspectorOptions() + +/** Runtime validation for {@link Config}. */ +export const Config: z = z.object({ + host: z.const('127.0.0.1').default('127.0.0.1'), + port: z.natural().max(65_535).default(9_230), + clientOrigins: z.array(z.string()).default([]), + captureFetch: z.boolean().default(true), + maxRequestBodyBytes: z.natural().min(1).default(libraryDefaults.maxRequestBodyBytes), + maxResponseBodyBytes: z.natural().min(1).default(libraryDefaults.maxResponseBodyBytes), + maxBodyChunkBytes: z.natural().min(1).default(libraryDefaults.maxBodyChunkBytes), + maxJournalBytes: z.natural().min(1).default(libraryDefaults.maxJournalBytes), + maxRetainedRequests: z.natural().min(1).default(libraryDefaults.maxRetainedRequests), + maxSourceFrameBytes: z.natural().min(1).default(libraryDefaults.maxSourceFrameBytes), + maxSourceRecordsPerFrame: z.natural().min(1).default(libraryDefaults.maxSourceRecordsPerFrame), + maxQueuedRecords: z.natural().min(1).default(libraryDefaults.maxQueuedRecords), + maxQueuedBytes: z.natural().min(1).default(libraryDefaults.maxQueuedBytes), + startupTimeoutMs: z.natural().min(1).default(libraryDefaults.startupTimeoutMs), + stopTimeoutMs: z.natural().min(1).default(libraryDefaults.stopTimeoutMs), + clientReconnectBaseMs: z.natural().min(1).default(libraryDefaults.clientReconnectBaseMs), + clientReconnectMaxMs: z.natural().min(1).default(libraryDefaults.clientReconnectMaxMs), + clientRuntimeTimeoutMs: z.natural().min(1).default(libraryDefaults.clientRuntimeTimeoutMs), + queryTimeoutMs: z.natural().min(1).default(libraryDefaults.queryTimeoutMs), + maxClientRuntimeObjects: z.natural().min(1).default(libraryDefaults.maxClientRuntimeObjects), + maxClientRuntimeProperties: z.natural().min(1).default(libraryDefaults.maxClientRuntimeProperties), + maxClientSourceBytes: z.natural().min(1).default(libraryDefaults.maxClientSourceBytes), + maxCordisNodes: z.natural().min(1).default(libraryDefaults.maxCordisNodes), + maxDisconnectedCordisTrees: z.natural().default(libraryDefaults.maxDisconnectedCordisTrees), +}) + +/** + * Apply the Host implementation from the repository-standard package entry. + * @param ctx - Host Cordis plugin context. + * @param config - Validated Inspector configuration. + */ +export async function apply(ctx: Context, config: Config): Promise { + await applyHost(ctx, config) +} diff --git a/packages/experimental/inspector/tests/client-runtime.client.spec.ts b/packages/experimental/inspector/tests/client-runtime.client.spec.ts new file mode 100644 index 0000000000..c07e0b85d2 --- /dev/null +++ b/packages/experimental/inspector/tests/client-runtime.client.spec.ts @@ -0,0 +1,247 @@ +/** Client-face Runtime behavior. */ + +import { afterEach, describe, expect, it } from 'vitest' +import { ClientRuntimeExecutor } from '../src/client/cdp/runtime.ts' +import type { + ClientRuntimeCommand, + ClientRuntimeRequestFrame, + ClientRuntimeResult, +} from '../src/shared/bridge/messages/runtime/index.ts' +import { + inspectorId, +} from '../src/shared/bridge/ids.ts' + +const sourceId = inspectorId<'InspectorSourceId'>('client-test', 'sourceId') +const generation = inspectorId<'InspectorSourceGeneration'>('generation-test', 'generation') +const sessionId = inspectorId<'ClientRuntimeSessionId'>('session-test', 'sessionId') +const secondSessionId = inspectorId<'ClientRuntimeSessionId'>('session-second', 'sessionId') + +describe('Client Runtime executor', () => { + afterEach(() => { + Reflect.deleteProperty(globalThis, '__clientRuntimeFixture') + Reflect.deleteProperty(globalThis, '__clientRuntimeGetterCalls') + }) + + it('retains RemoteObjects, reads descriptors lazily, calls functions, and releases groups', async () => { + Reflect.set(globalThis, '__clientRuntimeGetterCalls', 0) + const fixture = { + value: 4, + get dangerous(): number { + const calls = Number(Reflect.get(globalThis, '__clientRuntimeGetterCalls')) + Reflect.set(globalThis, '__clientRuntimeGetterCalls', calls + 1) + return 99 + }, + } + Object.defineProperty(fixture, Symbol.toStringTag, { + get() { + const calls = Number(Reflect.get(globalThis, '__clientRuntimeGetterCalls')) + Reflect.set(globalThis, '__clientRuntimeGetterCalls', calls + 1) + return 'DangerousTag' + }, + }) + Reflect.set(globalThis, '__clientRuntimeFixture', fixture) + const runtime = new ClientRuntimeExecutor({ + maxObjectsPerSession: 100, + maxPropertiesPerResult: 100, + maxResponseBytes: 32_768, + }) + + const evaluated = success(await runtime.execute(frame({ + op: 'evaluate', + expression: 'globalThis.__clientRuntimeFixture', + objectGroup: 'console', + generatePreview: true, + })), 'evaluate') + const handle = evaluated.completion.result.object?.handle + if (handle === undefined) throw new Error('evaluate did not return a Client object handle') + + const properties = success(await runtime.execute(frame({ + op: 'get-properties', + handle, + ownProperties: true, + })), 'get-properties') + const valueProperty = properties.properties.find(property => property.name === 'value') + const getterProperty = properties.properties.find(property => property.name === 'dangerous') + expect(valueProperty?.value).toMatchObject({ descriptor: { type: 'number', value: 4 } }) + expect(getterProperty?.get).toMatchObject({ descriptor: { type: 'function' } }) + expect(Reflect.get(globalThis, '__clientRuntimeGetterCalls')).toBe(0) + + const called = success(await runtime.execute(frame({ + op: 'call-function', + functionDeclaration: 'function (increment) { return this.value + increment }', + receiver: handle, + arguments: [{ kind: 'value', value: 3 }], + returnByValue: true, + })), 'call-function') + expect(called.completion.result).toMatchObject({ descriptor: { type: 'number', value: 7 } }) + + success(await runtime.execute(frame({ op: 'release-object-group', objectGroup: 'console' })), 'release-object-group') + const released = await runtime.execute(frame({ op: 'get-properties', handle })) + expect(released.outcome).toEqual({ + ok: false, + error: { code: 'object-not-found', message: 'Client RemoteObject was released' }, + }) + }) + + it('keeps evaluated exceptions separate from transport failures', async () => { + const runtime = new ClientRuntimeExecutor({ + maxObjectsPerSession: 100, + maxPropertiesPerResult: 100, + maxResponseBytes: 32_768, + }) + const result = success(await runtime.execute(frame({ + op: 'evaluate', + expression: 'throw new TypeError("bad value")', + })), 'evaluate') + expect(result.completion.exceptionDetails).toMatchObject({ + text: 'Uncaught', + exception: { descriptor: { type: 'object', subtype: 'error' } }, + }) + expect(result.completion.result).toMatchObject({ descriptor: { type: 'object', subtype: 'error' } }) + }) + + it('preserves non-JSON primitives and reports bounded async execution failures', async () => { + const runtime = new ClientRuntimeExecutor({ + maxObjectsPerSession: 100, + maxPropertiesPerResult: 100, + maxResponseBytes: 32_768, + }) + const values = [ + ['NaN', { descriptor: { type: 'number', unserializableValue: 'NaN' } }], + ['-0', { descriptor: { type: 'number', unserializableValue: '-0' } }], + ['12n', { descriptor: { type: 'bigint', unserializableValue: '12n' } }], + ['null', { descriptor: { type: 'object', subtype: 'null', value: null } }], + ] as const + for (const [expression, expected] of values) { + const result = success(await runtime.execute(frame({ op: 'evaluate', expression })), 'evaluate') + expect(result.completion.result).toMatchObject(expected) + } + const fn = success(await runtime.execute(frame({ + op: 'evaluate', + expression: '(value) => value', + generatePreview: true, + })), 'evaluate') + expect(fn.completion.result).toMatchObject({ descriptor: { type: 'function' } }) + expect(fn.completion.result.descriptor.preview).toBeUndefined() + + const timedOut = await runtime.execute(frame({ + op: 'evaluate', + expression: 'new Promise(() => {})', + awaitPromise: true, + timeoutMs: 1, + })) + expect(timedOut.outcome).toMatchObject({ ok: false, error: { code: 'timeout' } }) + }) + + it('rolls back only objects allocated by the failing concurrent request', async () => { + const runtime = new ClientRuntimeExecutor({ + maxObjectsPerSession: 100, + maxPropertiesPerResult: 100, + maxResponseBytes: 32_768, + }) + const blocked = runtime.execute(frame({ + op: 'evaluate', + expression: 'new Promise(() => {})', + awaitPromise: true, + timeoutMs: 10, + })) + const completed = success(await runtime.execute(frame({ + op: 'evaluate', + expression: '({ retainedByConcurrentRequest: true })', + })), 'evaluate') + const handle = completed.completion.result.object?.handle + if (handle === undefined) throw new Error('concurrent evaluation did not retain an object') + + await expect(blocked).resolves.toMatchObject({ outcome: { ok: false, error: { code: 'timeout' } } }) + const properties = success(await runtime.execute(frame({ + op: 'get-properties', + handle, + ownProperties: true, + })), 'get-properties') + expect(properties.properties.find(property => property.name === 'retainedByConcurrentRequest')?.value) + .toMatchObject({ descriptor: { value: true } }) + }) + + it('rejects oversized by-value results before they enter the source transport', async () => { + const runtime = new ClientRuntimeExecutor({ + maxObjectsPerSession: 100, + maxPropertiesPerResult: 100, + maxResponseBytes: 256, + }) + const response = await runtime.execute(frame({ + op: 'evaluate', + expression: '"x".repeat(1000)', + returnByValue: true, + })) + expect(response.outcome).toMatchObject({ ok: false, error: { code: 'result-too-large' } }) + }) + + it('drops every retained handle when its DevTools Runtime session closes', async () => { + const runtime = new ClientRuntimeExecutor({ + maxObjectsPerSession: 100, + maxPropertiesPerResult: 100, + maxResponseBytes: 32_768, + }) + const evaluated = success(await runtime.execute(frame({ + op: 'evaluate', + expression: '({ retained: true })', + })), 'evaluate') + const handle = evaluated.completion.result.object?.handle + if (handle === undefined) throw new Error('evaluate did not return a Client object handle') + + runtime.closeSession(sessionId) + const response = await runtime.execute(frame({ op: 'get-properties', handle })) + expect(response.outcome).toMatchObject({ ok: false, error: { code: 'object-not-found' } }) + }) + + it('serializes Console objects into isolated DevTools sessions', async () => { + const runtime = new ClientRuntimeExecutor({ + maxObjectsPerSession: 100, + maxPropertiesPerResult: 100, + maxResponseBytes: 32_768, + }) + const value = { owner: 'console' } + const first = runtime.consoleEvent(sessionId, 'log', [value], 12) + const second = runtime.consoleEvent(secondSessionId, 'log', [value], 12) + if (first?.type !== 'console-api' || second?.type !== 'console-api') { + throw new Error('Console event was unexpectedly dropped') + } + const firstHandle = first.event.arguments[0]?.object?.handle + const secondHandle = second.event.arguments[0]?.object?.handle + if (firstHandle === undefined || secondHandle === undefined) throw new Error('Console object was not retained') + + runtime.releaseObjectGroup(sessionId, 'console') + expect((await runtime.execute(frame({ op: 'get-properties', handle: firstHandle }))).outcome) + .toMatchObject({ ok: false, error: { code: 'object-not-found' } }) + const properties = success(await runtime.execute( + frame({ op: 'get-properties', handle: secondHandle }, secondSessionId), + ), 'get-properties').properties + expect(properties.find(property => property.name === 'owner')?.value?.descriptor.value).toBe('console') + }) +}) + +let nextRequestId = 0 + +function frame( + command: ClientRuntimeCommand, + owner: ClientRuntimeRequestFrame['sessionId'] = sessionId, +): ClientRuntimeRequestFrame { + return { + v: 0, + t: 'client-runtime/request', + sourceId, + generation, + sessionId: owner, + requestId: inspectorId<'ClientRuntimeRequestId'>(`request-${String(++nextRequestId)}`, 'requestId'), + command, + } +} + +function success( + response: Awaited>, + operation: Operation, +): Extract { + if (!response.outcome.ok) throw new Error(response.outcome.error.message) + if (response.outcome.result.op !== operation) throw new Error('unexpected Client Runtime result') + return response.outcome.result as Extract +} diff --git a/packages/experimental/inspector/tests/client-sources.client.spec.ts b/packages/experimental/inspector/tests/client-sources.client.spec.ts new file mode 100644 index 0000000000..06d790214d --- /dev/null +++ b/packages/experimental/inspector/tests/client-sources.client.spec.ts @@ -0,0 +1,84 @@ +/** Client-face source catalog behavior. */ + +import { describe, expect, it } from 'vitest' +import { ClientSourceCatalog } from '../src/client/cdp/sources.ts' +import { inspectorId } from '../src/shared/bridge/ids.ts' + +const scriptKey = inspectorId<'RuntimeScriptKey'>('bundle', 'scriptKey') + +describe('Client source catalog', () => { + it('describes scripts and transfers UTF-8 source and maps in bounded chunks', async () => { + const source = 'const greeting = "你好"\nconsole.log(greeting)\n' + const sourceMap = JSON.stringify({ version: 3, sources: ['client.ts'], mappings: 'AAAA' }) + const catalog = new ClientSourceCatalog([{ + scriptKey, + url: 'http://client.test/plugins/inspector/client.js?rev=abc', + hash: 'abc', + sourceMapUrl: 'http://client.test/plugins/inspector/client.js.map?rev=abc', + isModule: false, + loadSource: async () => source, + loadSourceMap: async () => sourceMap, + }]) + + await expect(catalog.execute({ op: 'list-scripts' }, 1_024)).resolves.toEqual({ + op: 'list-scripts', + scripts: [{ + scriptKey, + url: 'http://client.test/plugins/inspector/client.js?rev=abc', + hash: 'abc', + buildId: '', + sourceMapUrl: 'http://client.test/plugins/inspector/client.js.map?rev=abc', + startLine: 0, + startColumn: 0, + endLine: 2, + endColumn: 0, + isModule: false, + length: source.length, + }], + }) + + const bytes: Uint8Array[] = [] + let offset = 0 + while (true) { + const result = await catalog.execute({ + op: 'get-content-chunk', + scriptKey, + content: 'source', + offset, + maxBytes: 7, + }, 1_024) + if (result.op !== 'get-content-chunk' || !result.available) throw new Error('missing source chunk') + bytes.push(Uint8Array.from(atob(result.data), character => character.charCodeAt(0))) + offset = result.nextOffset + if (result.eof) break + } + const combined = new Uint8Array(bytes.reduce((total, chunk) => total + chunk.byteLength, 0)) + let cursor = 0 + for (const chunk of bytes) { + combined.set(chunk, cursor) + cursor += chunk.byteLength + } + expect(new TextDecoder().decode(combined)).toBe(source) + + const map = await catalog.execute({ + op: 'get-content-chunk', + scriptKey, + content: 'source-map', + offset: 0, + maxBytes: 1_024, + }, 1_024) + if (map.op !== 'get-content-chunk' || !map.available) throw new Error('missing source map') + expect(new TextDecoder().decode(Uint8Array.from(atob(map.data), character => character.charCodeAt(0)))) + .toBe(sourceMap) + }) + + it('rejects assets above the configured aggregate limit', async () => { + const catalog = new ClientSourceCatalog([{ + scriptKey, + url: 'http://client.test/client.js', + hash: 'abc', + loadSource: async () => 'x'.repeat(101), + }]) + await expect(catalog.execute({ op: 'list-scripts' }, 100)).rejects.toMatchObject({ code: 'result-too-large' }) + }) +}) diff --git a/packages/experimental/inspector/tests/client-stack.client.spec.ts b/packages/experimental/inspector/tests/client-stack.client.spec.ts new file mode 100644 index 0000000000..ed3de67867 --- /dev/null +++ b/packages/experimental/inspector/tests/client-stack.client.spec.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { parseClientStack } from '../src/client/cdp/stack.ts' +import { inspectorId } from '../src/shared/bridge/ids.ts' + +describe('Client stack projection', () => { + it('normalizes browser line numbers and associates known source URLs', () => { + const key = inspectorId<'RuntimeScriptKey'>('client-bundle', 'scriptKey') + const stack = parseClientStack([ + 'Error', + ' at capture (http://client.test/client.js?rev=1:10:4)', + ' at http://client.test/app.js:20:8', + ].join('\n'), url => url.includes('/client.js') ? key : undefined, 0) + expect(stack).toEqual({ + callFrames: [ + { + functionName: 'capture', + scriptKey: key, + url: 'http://client.test/client.js?rev=1', + lineNumber: 9, + columnNumber: 3, + }, + { + functionName: '', + url: 'http://client.test/app.js', + lineNumber: 19, + columnNumber: 7, + }, + ], + }) + }) +}) diff --git a/packages/experimental/inspector/tests/loader-composition.host.spec.ts b/packages/experimental/inspector/tests/loader-composition.host.spec.ts new file mode 100644 index 0000000000..35fa9103b5 --- /dev/null +++ b/packages/experimental/inspector/tests/loader-composition.host.spec.ts @@ -0,0 +1,82 @@ +/** Host Loader composition behavior. */ + +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import Include from '@deepseek-ai/cordis-plugin-include' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import WebServer from '@deepseek-ai/dsh-host-webserver' +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as Inspector from '../src/index.ts' + +let root: string | undefined +let context: Context | undefined + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) + +describe('experimental Inspector through a real Loader composition', () => { + it('loads the named-export Host face from cordis.yml and releases its endpoint', async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-inspector-loader-')) + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-host-webserver'", + ' config:', + " host: '127.0.0.1'", + ' port: 0', + "- name: '@deepseek-ai/dsh-experimental-inspector'", + ' config:', + ' port: 0', + ' captureFetch: false', + '', + ].join('\n')) + + context = new Context() + context.baseUrl = pathToFileURL(root).href + '/' + await context.plugin(Loader) + expect('default' in Inspector).toBe(false) + const plugin = context.loader.unwrapExports(Inspector) as Record + expect(plugin).toMatchObject({ + name: Inspector.name, + inject: Inspector.inject, + Config: Inspector.Config, + apply: Inspector.apply, + }) + context.loader.builtins.include = Include + const modules = new Map([ + ['@deepseek-ai/dsh-host-webserver', WebServer], + ['@deepseek-ai/dsh-experimental-inspector', Inspector], + ]) + context.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }, + } as unknown as NonNullable + await context.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(configPath).href }, + }) + await context.loader.await() + + expect([...context.loader.entries()] + .filter(entry => entry.fiber === undefined && !entry.disabled)) + .toEqual([]) + await vi.waitFor(async () => { + expect((await context!.inspector.cordis.getTree()).host?.source.kind).toBe('host') + }) + + const inspectorEntry = [...context.loader.entries()] + .find(entry => entry.options.name === '@deepseek-ai/dsh-experimental-inspector') + expect(inspectorEntry?.fiber).toBeDefined() + await inspectorEntry!.fiber!.dispose() + expect(context.get('inspector')).toBeUndefined() + }) +}) diff --git a/packages/experimental/inspector/tests/plugin.client.spec.ts b/packages/experimental/inspector/tests/plugin.client.spec.ts new file mode 100644 index 0000000000..ea3cbd3e31 --- /dev/null +++ b/packages/experimental/inspector/tests/plugin.client.spec.ts @@ -0,0 +1,302 @@ +// @vitest-environment jsdom + +import { Context } from '@deepseek-ai/cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { apply } from '../src/client/index.ts' +import type { InspectorClientBootstrap } from '../src/shared/bridge/messages/control.ts' + +class FakeWebSocket extends EventTarget { + static readonly CONNECTING = 0 + static readonly OPEN = 1 + static readonly CLOSING = 2 + static readonly CLOSED = 3 + static readonly sockets: FakeWebSocket[] = [] + + readonly sent: string[] = [] + readonly url: string + readonly protocol: string + readyState = FakeWebSocket.CONNECTING + bufferedAmount = 0 + + constructor(url: string | URL, protocols?: string | string[]) { + super() + this.url = String(url) + this.protocol = typeof protocols === 'string' ? protocols : protocols?.[0] ?? '' + FakeWebSocket.sockets.push(this) + } + + send(data: string): void { + this.sent.push(data) + } + + close(): void { + if (this.readyState === FakeWebSocket.CLOSED) return + this.readyState = FakeWebSocket.CLOSED + this.dispatchEvent(new Event('close')) + } + + open(): void { + this.readyState = FakeWebSocket.OPEN + this.dispatchEvent(new Event('open')) + } + + receive(value: unknown): void { + this.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(value) })) + } +} + +const bootstrap: InspectorClientBootstrap = { + endpoint: 'ws://127.0.0.1:9230/ingest', + protocol: 'dsh-inspector-v0-token', + maxQueuedRecords: 16, + maxQueuedBytes: 16_384, + maxRecordsPerFrame: 8, + maxFrameBytes: 32_768, + reconnectBaseMs: 10, + reconnectMaxMs: 20, + queryTimeoutMs: 100, + maxRuntimeObjectsPerSession: 100, + maxRuntimePropertiesPerResult: 100, + maxClientSourceBytes: 1_048_576, + maxCordisNodes: 100, +} + +describe('experimental Inspector Client plugin', () => { + const nativeWebSocket = globalThis.WebSocket + const nativeFetch = globalThis.fetch + + afterEach(() => { + FakeWebSocket.sockets.length = 0 + globalThis.WebSocket = nativeWebSocket + globalThis.fetch = nativeFetch + delete globalThis.__DSH_INSPECTOR__ + Reflect.deleteProperty(globalThis, '__DSH_BOOT__') + }) + + it('provides ctx.inspector and sends observations after the Worker accepts the source', async () => { + globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket + globalThis.__DSH_INSPECTOR__ = bootstrap + const ctx = new Context() + const fiber = ctx.plugin({ apply }) + await fiber.await() + const socket = FakeWebSocket.sockets[0]! + expect(socket.url).toBe(bootstrap.endpoint) + expect(socket.protocol).toBe(bootstrap.protocol) + socket.open() + const open = JSON.parse(socket.sent[0]!) as { + source: { sourceId: string; generation: string } + } + socket.receive({ + v: 0, + t: 'source/accepted', + sourceId: open.source.sourceId, + generation: open.source.generation, + }) + expect(JSON.parse(socket.sent[1]!) as unknown).toMatchObject({ + t: 'source/replace', + records: [{ topic: 'cordis/tree', payload: { schemaVersion: 0, truncated: false } }], + }) + + const treePromise = ctx.inspector.cordis.getTree() + const treeRequest = socket.sent.map(value => JSON.parse(value) as { t: string; requestId?: string }) + .find(frame => frame.t === 'query/request') + expect(treeRequest?.requestId).toBeTypeOf('string') + socket.receive({ + v: 0, + t: 'query/response', + sourceId: open.source.sourceId, + generation: open.source.generation, + requestId: treeRequest!.requestId, + outcome: { + ok: true, + result: { op: 'cordis-tree/get', tree: { schemaVersion: 0, host: null, clients: [] } }, + }, + }) + await expect(treePromise).resolves.toEqual({ schemaVersion: 0, host: null, clients: [] }) + + ctx.inspector.publish('client/probe', { ready: true }, 7) + const append = socket.sent.map(value => JSON.parse(value) as { + t: string + records: Array<{ topic: string; monotonicMs: number; payload: unknown }> + }).find(frame => frame.t === 'source/append' + && frame.records.some(record => record.topic === 'client/probe')) + expect(append).toMatchObject({ + t: 'source/append', + records: [{ topic: 'client/probe', monotonicMs: 7, payload: { ready: true } }], + }) + + document.title = 'Inspector Client Realm' + socket.receive({ + v: 0, + t: 'client-runtime/request', + sourceId: open.source.sourceId, + generation: open.source.generation, + sessionId: 'devtools-1', + requestId: 'runtime-1', + command: { op: 'evaluate', expression: 'document.title', returnByValue: true }, + }) + await vi.waitFor(() => { + const response = socket.sent.map(value => JSON.parse(value) as { requestId?: string }) + .find(frame => frame.requestId === 'runtime-1') + expect(response).toMatchObject({ + t: 'client-runtime/response', + sessionId: 'devtools-1', + requestId: 'runtime-1', + outcome: { + ok: true, + result: { op: 'evaluate', completion: { result: { descriptor: { value: 'Inspector Client Realm' } } } }, + }, + }) + }) + + await fiber.dispose() + expect(JSON.parse(socket.sent.at(-1)!)).toMatchObject({ t: 'source/close' }) + expect(socket.readyState).toBe(FakeWebSocket.CLOSED) + }) + + it('keeps the realm source id and rotates the transport generation on reconnect', async () => { + globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket + globalThis.__DSH_INSPECTOR__ = bootstrap + const ctx = new Context() + const fiber = ctx.plugin({ apply }) + await fiber.await() + const firstSocket = FakeWebSocket.sockets[0]! + firstSocket.open() + const firstOpen = JSON.parse(firstSocket.sent[0]!) as { + source: { sourceId: string; generation: string } + } + + firstSocket.close() + await vi.waitFor(() => { expect(FakeWebSocket.sockets).toHaveLength(2) }) + const secondSocket = FakeWebSocket.sockets[1]! + secondSocket.open() + const secondOpen = JSON.parse(secondSocket.sent[0]!) as { + source: { sourceId: string; generation: string } + } + expect(secondOpen.source.sourceId).toBe(firstOpen.source.sourceId) + expect(secondOpen.source.generation).not.toBe(firstOpen.source.generation) + + await fiber.dispose() + }) + + it('does not report queue loss again after a replacement absorbs it', async () => { + globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket + globalThis.__DSH_INSPECTOR__ = { ...bootstrap, maxQueuedRecords: 1 } + const ctx = new Context() + const fiber = ctx.plugin({ apply }) + await fiber.await() + const socket = FakeWebSocket.sockets[0]! + + ctx.inspector.publish('client/first', { ordinal: 1 }) + ctx.inspector.publish('client/second', { ordinal: 2 }) + socket.open() + const open = JSON.parse(socket.sent[0]!) as { + source: { sourceId: string; generation: string } + } + socket.receive({ + v: 0, + t: 'source/accepted', + sourceId: open.source.sourceId, + generation: open.source.generation, + }) + + const replacement = JSON.parse(socket.sent[1]!) as { nextSequence: number } + const append = JSON.parse(socket.sent[2]!) as { + firstSequence: number + droppedBefore: number + records: Array<{ topic: string }> + } + expect(append).toMatchObject({ + firstSequence: replacement.nextSequence, + droppedBefore: 0, + records: [{ topic: 'client/second' }], + }) + + await fiber.dispose() + }) + + it('discovers and serves its built Client bundle through the source protocol', async () => { + globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket + globalThis.__DSH_INSPECTOR__ = bootstrap + Reflect.set(globalThis, '__DSH_BOOT__', { + rev: 'graph', + entries: [{ + id: '@deepseek-ai/dsh-experimental-inspector', + url: '/plugins/@deepseek-ai/dsh-experimental-inspector/client.js?rev=bundle-rev', + rev: 'bundle-rev', + }], + }) + const source = 'const clientBundleMarker = "你好"\n' + const sourceMap = '{"version":3,"sources":["client/index.ts"]}' + globalThis.fetch = vi.fn(async (input: string | URL | Request) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + return new Response(url.includes('.js.map') ? sourceMap : source) + }) + + const ctx = new Context() + const fiber = ctx.plugin({ apply }) + await fiber.await() + const socket = FakeWebSocket.sockets[0]! + socket.open() + const open = JSON.parse(socket.sent[0]!) as { + source: { sourceId: string; generation: string; capabilities: Array<{ type: string }> } + } + expect(open.source.capabilities).toEqual(expect.arrayContaining([{ type: 'client-sources' }])) + socket.receive({ + v: 0, + t: 'source/accepted', + sourceId: open.source.sourceId, + generation: open.source.generation, + }) + socket.receive({ + v: 0, + t: 'client-sources/request', + sourceId: open.source.sourceId, + generation: open.source.generation, + sessionId: 'source-session-1', + requestId: 'source-request-1', + command: { op: 'list-scripts' }, + }) + + let scriptKey: string | undefined + await vi.waitFor(() => { + const response = socket.sent.map(value => JSON.parse(value) as { + requestId?: string + outcome?: { result?: { scripts?: Array<{ scriptKey: string; url: string; sourceMapUrl: string }> } } + }).find(frame => frame.requestId === 'source-request-1') + const script = response?.outcome?.result?.scripts?.[0] + expect(script?.url).toContain('/plugins/@deepseek-ai/dsh-experimental-inspector/client.js?rev=bundle-rev') + expect(script?.sourceMapUrl) + .toContain('/plugins/@deepseek-ai/dsh-experimental-inspector/client.js.map?rev=bundle-rev') + scriptKey = script?.scriptKey + }) + socket.receive({ + v: 0, + t: 'client-sources/request', + sourceId: open.source.sourceId, + generation: open.source.generation, + sessionId: 'source-session-1', + requestId: 'source-request-2', + command: { op: 'get-content-chunk', scriptKey, content: 'source', offset: 0, maxBytes: 1_024 }, + }) + await vi.waitFor(() => { + const response = socket.sent.map(value => JSON.parse(value) as { + requestId?: string + outcome?: { result?: { data?: string; eof?: boolean } } + }).find(frame => frame.requestId === 'source-request-2') + expect(response?.outcome?.result?.eof).toBe(true) + const bytes = Uint8Array.from(atob(response?.outcome?.result?.data ?? ''), character => character.charCodeAt(0)) + expect(new TextDecoder().decode(bytes)).toBe(source) + }) + + await fiber.dispose() + }) + + it('fails loud when the Host did not inject a bootstrap', async () => { + globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket + const ctx = new Context() + const fiber = ctx.plugin({ apply }) + await expect(fiber).rejects.toThrow('Host bootstrap is missing') + await fiber.dispose() + }) +}) diff --git a/packages/experimental/inspector/tests/plugin.host.spec.ts b/packages/experimental/inspector/tests/plugin.host.spec.ts new file mode 100644 index 0000000000..f69a9cc509 --- /dev/null +++ b/packages/experimental/inspector/tests/plugin.host.spec.ts @@ -0,0 +1,136 @@ +import { createServer } from 'node:http' +import type { AddressInfo } from 'node:net' +import { Context } from '@deepseek-ai/cordis' +import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver' +import WebSocket, { type RawData } from 'ws' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { apply, Config, inject, name, startInspector } from '../src/index.ts' +import { isPlainObject } from '../src/shared/json.ts' + +interface CdpResponse { + readonly id: number + readonly result?: Record +} + +describe('experimental Inspector Host plugin', () => { + let context: Context | undefined + + afterEach(async () => { + await context?.fiber.dispose() + context = undefined + vi.restoreAllMocks() + }) + + it('starts the Worker, provides ctx.inspector, injects Client bootstrap, and disposes', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined) + context = new Context() + context.provide('webServer', {} as WebServer) + const fiber = context.plugin( + { name, inject: [...inject], Config, apply }, + { port: 0, captureFetch: false }, + ) + await fiber.await() + + const rows: IndexInjection[] = [] + context.emit('webserver/index-inject', rows) + const bootstrap = rows.find(row => row.kind === 'global' && row.name === '__DSH_INSPECTOR__') + expect(bootstrap).toMatchObject({ kind: 'global', name: '__DSH_INSPECTOR__' }) + expect(log).toHaveBeenCalledWith(expect.stringMatching(/^dsh inspector: devtools:\/\//u)) + expect(context.inspector).toBeDefined() + await vi.waitFor(async () => { + const tree = await context!.inspector.cordis.getTree() + expect(tree.host?.source.kind).toBe('host') + }) + expect(() => { context!.inspector.publish('', {}) }).toThrow('topic must contain 1 to 128 characters') + expect(() => { context!.inspector.publish('host/invalid-time', {}, Number.NaN) }).toThrow('monotonicMs must be finite') + context.inspector.publish('host/plugin-probe', { ready: true }) + + const value = bootstrap?.kind === 'global' ? bootstrap.value : undefined + const endpoint = value as { endpoint: string; protocol: string } + const authority = new URL(endpoint.endpoint) + const targets: unknown = await fetch(`http://${authority.host}/json`).then(response => response.json()) + if (!Array.isArray(targets) || !isPlainObject(targets[0]) || typeof targets[0].webSocketDebuggerUrl !== 'string') { + throw new Error('Inspector discovery did not return a target') + } + const socket = new WebSocket(targets[0].webSocketDebuggerUrl) + await new Promise((resolve, reject) => { + socket.once('open', () => { resolve() }) + socket.once('error', reject) + }) + const response = new Promise((resolve) => { + socket.on('message', (data) => { + const message = JSON.parse(rawText(data)) as CdpResponse + if (message.id === 1) resolve(message) + }) + }) + socket.send(JSON.stringify({ id: 1, method: 'DSHInspector.getSources' })) + await vi.waitFor(async () => { + const sources = (await response).result?.sources as Array<{ topics: Record }> + expect(sources.some(source => source.topics['host/plugin-probe'] === 1)).toBe(true) + }) + socket.close() + await new Promise((resolve) => { socket.once('close', () => { resolve() }) }) + + await fiber.dispose() + expect(rows).toHaveLength(1) + const afterDispose: IndexInjection[] = [] + context.emit('webserver/index-inject', afterDispose) + expect(afterDispose).toEqual([]) + }) + + it('closes the started Worker when a later plugin registration fails', async () => { + const port = await availablePort() + context = new Context() + context.provide('webServer', {} as WebServer) + context.provide('inspector', { + publish: () => undefined, + cordis: { getTree: () => Promise.reject(new Error('unused test service')) }, + }) + + const fiber = context.plugin( + { name, inject: [...inject], Config, apply }, + { port, captureFetch: false }, + ) + await expect(fiber.await()).rejects.toThrow('service "inspector" has been registered') + + const replacement = await startInspector({ port, captureFetch: false }) + expect(new URL(replacement.endpoint.httpUrl).port).toBe(String(port)) + await replacement.close() + }) + + it('closes the Worker when fetch capture installation fails', async () => { + const port = await availablePort() + const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'fetch') + const nativeFetch = globalThis.fetch + Object.defineProperty(globalThis, 'fetch', { + configurable: true, + get: () => nativeFetch, + }) + try { + await expect(startInspector({ port })).rejects.toThrow('globalThis.fetch is an accessor') + } finally { + if (descriptor === undefined) Reflect.deleteProperty(globalThis, 'fetch') + else Object.defineProperty(globalThis, 'fetch', descriptor) + } + + const replacement = await startInspector({ port, captureFetch: false }) + expect(new URL(replacement.endpoint.httpUrl).port).toBe(String(port)) + await replacement.close() + }) +}) + +async function availablePort(): Promise { + const server = createServer() + await new Promise((resolve) => { server.listen(0, '127.0.0.1', resolve) }) + const port = (server.address() as AddressInfo).port + await new Promise((resolve, reject) => { + server.close((error) => { if (error === undefined) resolve(); else reject(error) }) + }) + return port +} + +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/port-selection.host.spec.ts b/packages/experimental/inspector/tests/port-selection.host.spec.ts new file mode 100644 index 0000000000..3b400b29f3 --- /dev/null +++ b/packages/experimental/inspector/tests/port-selection.host.spec.ts @@ -0,0 +1,42 @@ +/** Host Worker port-selection behavior. */ + +import { createServer, type Server } from 'node:http' +import { afterEach, describe, expect, it } from 'vitest' +import { startInspector, type InspectorHandle } from '../src/host/bridge/controller.ts' + +describe('Inspector endpoint port selection', () => { + let blocker: Server | undefined + let inspector: InspectorHandle | undefined + + afterEach(async () => { + await inspector?.close() + inspector = undefined + if (blocker?.listening === true) { + await new Promise((resolve) => { blocker!.close(() => { resolve() }) }) + } + blocker = undefined + }) + + it('advances from an occupied starting port and publishes the selected port', async () => { + blocker = createServer() + await new Promise((resolve, reject) => { + blocker!.once('error', reject) + blocker!.listen(0, '127.0.0.1', () => { + blocker!.off('error', reject) + resolve() + }) + }) + const occupiedAddress = blocker.address() + if (occupiedAddress === null || typeof occupiedAddress === 'string') { + throw new Error('test server did not bind a TCP port') + } + + inspector = await startInspector({ port: occupiedAddress.port, captureFetch: false }) + const selectedPort = Number(new URL(inspector.endpoint.httpUrl).port) + + expect(selectedPort).toBeGreaterThan(occupiedAddress.port) + expect(new URL(inspector.endpoint.webSocketDebuggerUrl).port).toBe(String(selectedPort)) + expect(new URL(inspector.endpoint.client.endpoint).port).toBe(String(selectedPort)) + await expect(fetch(new URL('json', inspector.endpoint.httpUrl)).then(response => response.status)).resolves.toBe(200) + }) +}) diff --git a/packages/experimental/inspector/tests/worker-lifecycle.host.spec.ts b/packages/experimental/inspector/tests/worker-lifecycle.host.spec.ts new file mode 100644 index 0000000000..3206cb0054 --- /dev/null +++ b/packages/experimental/inspector/tests/worker-lifecycle.host.spec.ts @@ -0,0 +1,35 @@ +/** Host-side Worker lifecycle behavior. */ + +import { Worker } from 'node:worker_threads' +import { describe, expect, it } from 'vitest' +import { InspectorWorkerLifecycle } from '../src/host/bridge/lifecycle.ts' + +describe('Inspector Worker lifecycle', () => { + it('keeps the runtime error listener and treats an already-exited Worker as stopped', async () => { + const worker = new Worker('setImmediate(() => { throw new Error("runtime crash") })', { eval: true }) + const lifecycle = new InspectorWorkerLifecycle(worker) + const failed = new Promise((resolve) => { lifecycle.markRunning(resolve) }) + + await expect(failed).resolves.toMatchObject({ message: 'runtime crash' }) + await expect(lifecycle.stop(100)).resolves.toBeUndefined() + expect(lifecycle.exitCode).toBeTypeOf('number') + }) + + it('reads readiness and completes graceful shutdown through one persistent owner', async () => { + const worker = new Worker([ + "const { parentPort } = require('node:worker_threads')", + "parentPort.postMessage({ type: 'ready', host: '127.0.0.1', port: 9230, targetId: 'test-target' })", + "parentPort.on('message', message => { if (message.type === 'shutdown') process.exit(0) })", + ].join('\n'), { eval: true }) + const lifecycle = new InspectorWorkerLifecycle(worker) + + await expect(lifecycle.waitForReady(1_000)).resolves.toMatchObject({ + host: '127.0.0.1', + port: 9_230, + targetId: 'test-target', + }) + lifecycle.markRunning(() => { throw new Error('graceful exit reported as unexpected') }) + await expect(lifecycle.stop(1_000)).resolves.toBeUndefined() + expect(lifecycle.exitCode).toBe(0) + }) +})