feat(inspector): connect Host and Client producers

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