mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
feat(inspector): connect Host and Client producers
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)}`)
|
||||
}
|
||||
@@ -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<typeof setTimeout> | 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
|
||||
}
|
||||
}
|
||||
@@ -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<typeof setTimeout> | 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)
|
||||
}
|
||||
}
|
||||
@@ -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 extends InspectorQuery>(query: Query): Promise<InspectorQueryResultFor<Query>> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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 extends InspectorQuery>(query: Query): Promise<InspectorQueryResultFor<Query>> {
|
||||
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<ReturnType<typeof parseWorkerSourceFrame>, { t: 'client-runtime/request' }>,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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)
|
||||
}
|
||||
@@ -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<ClientRemoteObjectHandle>,
|
||||
) => 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<ClientRuntimeSessionId>()
|
||||
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])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<ClientRemoteObjectHandle, StoredObject>()
|
||||
private readonly groups = new Map<string, Set<ClientRemoteObjectHandle>>()
|
||||
private readonly allocations = new Map<ClientObjectAllocation, Set<ClientRemoteObjectHandle>>()
|
||||
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<object>()
|
||||
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<object>()
|
||||
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'],
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<PropertyKey>()
|
||||
const visited = new Set<object>()
|
||||
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 }),
|
||||
}
|
||||
}
|
||||
@@ -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<ClientRuntimeSessionId, ClientRuntimeSession>()
|
||||
|
||||
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<ClientRuntimeResponseFrame> {
|
||||
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<ClientRemoteObjectHandle> | undefined {
|
||||
const session = this.session(sessionId)
|
||||
const allocation = session.beginAllocation()
|
||||
try {
|
||||
const event: RuntimeConsoleBackendEvent<ClientRemoteObjectHandle> = {
|
||||
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<ClientRemoteObjectHandle> | undefined {
|
||||
const session = this.session(sessionId)
|
||||
const allocation = session.beginAllocation()
|
||||
try {
|
||||
const event: RuntimeConsoleBackendEvent<ClientRemoteObjectHandle> = {
|
||||
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<ClientRuntimeResult> {
|
||||
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<ClientRuntimeCommand, { op: 'evaluate' }>,
|
||||
allocation: ClientObjectAllocation,
|
||||
): Promise<ClientRuntimeCompletion> {
|
||||
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<ClientRuntimeCommand, { op: 'call-function' }>,
|
||||
allocation: ClientObjectAllocation,
|
||||
): Promise<ClientRuntimeCompletion> {
|
||||
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<ClientRuntimeCommand, { op: 'await-promise' }>,
|
||||
allocation: ClientObjectAllocation,
|
||||
): Promise<ClientRuntimeCompletion> {
|
||||
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<unknown> {
|
||||
if (timeoutMs === undefined) return await value
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
return await Promise.race([
|
||||
Promise.resolve(value),
|
||||
new Promise<never>((_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)}`)
|
||||
}
|
||||
@@ -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<string>
|
||||
loadSourceMap?(): Promise<string | undefined>
|
||||
}
|
||||
|
||||
interface LoadedAsset {
|
||||
readonly asset: ClientSourceAsset
|
||||
source?: Promise<string>
|
||||
sourceBytes?: Promise<Uint8Array>
|
||||
sourceMapBytes?: Promise<Uint8Array | undefined>
|
||||
}
|
||||
|
||||
/** 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<RuntimeScriptKey, LoadedAsset>()
|
||||
|
||||
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<ClientSourceResult> {
|
||||
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<ClientScriptDescriptor> {
|
||||
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<string> {
|
||||
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<Uint8Array> {
|
||||
entry.sourceBytes ??= this.source(entry, maxContentBytes).then(source => new TextEncoder().encode(source))
|
||||
return entry.sourceBytes
|
||||
}
|
||||
|
||||
private sourceMapBytes(entry: LoadedAsset, maxContentBytes: number): Promise<Uint8Array | undefined> {
|
||||
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<string, unknown> | 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<string> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
/** Browser Client entry for the experimental Inspector Cordis plugin. */
|
||||
|
||||
export * from './plugin.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 : ''
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<InspectorHandle> {
|
||||
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<MessagePort> = { 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<void> | undefined
|
||||
return {
|
||||
endpoint,
|
||||
source,
|
||||
close(): Promise<void> {
|
||||
closing ??= closeInspector(lifecycle, source, fetchObserver, spec.stopTimeoutMs)
|
||||
return closing
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function spawnWorker(boot: InspectorWorkerBoot<MessagePort>): 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<void> {
|
||||
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
|
||||
}
|
||||
@@ -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)}`)
|
||||
}
|
||||
@@ -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<number>()
|
||||
private readonly failureResolution = Promise.withResolvers<Error>()
|
||||
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<Extract<InspectorWorkerControl, { type: 'ready' }>> {
|
||||
let timer: NodeJS.Timeout | undefined
|
||||
let onMessage: ((value: unknown) => void) | undefined
|
||||
const message = new Promise<Extract<InspectorWorkerControl, { type: 'ready' }>>((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<void> {
|
||||
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<void> {
|
||||
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)}`,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 extends InspectorQuery>(query: Query): Promise<InspectorQueryResultFor<Query>> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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 extends InspectorQuery>(query: Query): Promise<InspectorQueryResultFor<Query>> {
|
||||
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}`) },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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)}`)
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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'
|
||||
@@ -0,0 +1,3 @@
|
||||
/** Host entry for the experimental Inspector Cordis plugin and library API. */
|
||||
|
||||
export * from './plugin.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),
|
||||
}
|
||||
}
|
||||
@@ -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<InspectorOptions, 'clientOrigins'> {
|
||||
/** 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<void> {
|
||||
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<ReturnType<typeof startInspector>>,
|
||||
disposers: readonly (() => unknown)[],
|
||||
): Promise<void> {
|
||||
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')
|
||||
}
|
||||
@@ -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<InspectorOptions, 'clientOrigins'> {
|
||||
/** Browser origins allowed to open the Client ingest WebSocket. */
|
||||
clientOrigins?: string[]
|
||||
}
|
||||
|
||||
const libraryDefaults = resolveInspectorOptions()
|
||||
|
||||
/** Runtime validation for {@link Config}. */
|
||||
export const Config: z<Config> = 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<void> {
|
||||
await applyHost(ctx, config)
|
||||
}
|
||||
@@ -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<Operation extends ClientRuntimeResult['op']>(
|
||||
response: Awaited<ReturnType<ClientRuntimeExecutor['execute']>>,
|
||||
operation: Operation,
|
||||
): Extract<ClientRuntimeResult, { op: Operation }> {
|
||||
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<ClientRuntimeResult, { op: Operation }>
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>
|
||||
expect(plugin).toMatchObject({
|
||||
name: Inspector.name,
|
||||
inject: Inspector.inject,
|
||||
Config: Inspector.Config,
|
||||
apply: Inspector.apply,
|
||||
})
|
||||
context.loader.builtins.include = Include
|
||||
const modules = new Map<string, unknown>([
|
||||
['@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<typeof context.loader.internal>
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>
|
||||
}
|
||||
|
||||
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<void>((resolve, reject) => {
|
||||
socket.once('open', () => { resolve() })
|
||||
socket.once('error', reject)
|
||||
})
|
||||
const response = new Promise<CdpResponse>((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<string, number> }>
|
||||
expect(sources.some(source => source.topics['host/plugin-probe'] === 1)).toBe(true)
|
||||
})
|
||||
socket.close()
|
||||
await new Promise<void>((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<number> {
|
||||
const server = createServer()
|
||||
await new Promise<void>((resolve) => { server.listen(0, '127.0.0.1', resolve) })
|
||||
const port = (server.address() as AddressInfo).port
|
||||
await new Promise<void>((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')
|
||||
}
|
||||
@@ -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<void>((resolve) => { blocker!.close(() => { resolve() }) })
|
||||
}
|
||||
blocker = undefined
|
||||
})
|
||||
|
||||
it('advances from an occupied starting port and publishes the selected port', async () => {
|
||||
blocker = createServer()
|
||||
await new Promise<void>((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)
|
||||
})
|
||||
})
|
||||
@@ -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<Error>((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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user