Files
deepseek-harness/packages/experimental/inspector/src/client/cdp/runtime.ts
T

424 lines
15 KiB
TypeScript

/** 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)}`)
}