diff --git a/knip.json b/knip.json index 6fa4cac826..026215eb4f 100644 --- a/knip.json +++ b/knip.json @@ -45,6 +45,15 @@ "@deepseek-ai/dsh-client-ui-directory-picker-native" ] }, + "packages/experimental/inspector": { + "entry": [ + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, "packages/extensions/cordis-host-runner": { "entry": [ "tests/**/*.spec.ts" diff --git a/packages/experimental/inspector/package.json b/packages/experimental/inspector/package.json new file mode 100644 index 0000000000..c13ed177d2 --- /dev/null +++ b/packages/experimental/inspector/package.json @@ -0,0 +1,67 @@ +{ + "name": "@deepseek-ai/dsh-experimental-inspector", + "description": "Experimental cross-realm CDP hub for Host debugging and Client Runtime inspection", + "version": "0.1.1-rc.2", + "private": true, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/experimental/inspector" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [], + "platform": "web", + "immediately": true + } + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "dependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-util-crypto": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^", + "ws": "^8.21.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-client-modules": "workspace:^", + "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/ws": "^8.18.1", + "playwright": "^1.49.0", + "tsx": "^4.19.2" + } +} diff --git a/packages/experimental/inspector/src/invariant.ts b/packages/experimental/inspector/src/invariant.ts new file mode 100644 index 0000000000..33dccfbe9e --- /dev/null +++ b/packages/experimental/inspector/src/invariant.ts @@ -0,0 +1,22 @@ +/** Package-owned invariant companion for the experimental Inspector. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-experimental-inspector' + +/** Cordis companion plugin name. */ +export const name = 'experimental-inspector-invariant' + +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: wire parsing, generations, Worker lifecycle, and CDP + * sessions reject invalid relationships in their owning operations. + */ +const install: InvariantInstaller = () => {} + +/** Register this package's invariant companion. */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/experimental/inspector/src/shared/bridge/buffer.ts b/packages/experimental/inspector/src/shared/bridge/buffer.ts new file mode 100644 index 0000000000..c058fbe18a --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/buffer.ts @@ -0,0 +1,157 @@ +/** Realm-neutral bounded buffering for Host and Client observation sources. */ + +import type { InspectorSourceGeneration, InspectorSourceId } from './ids.ts' +import { isJsonValue, jsonByteLength, type InspectorJsonValue } from '../json.ts' +import type { InspectorRecordInput, SourceAppendFrame, SourceReplaceFrame } from './messages/observation.ts' +import { INSPECTOR_PROTOCOL_VERSION } from './version.ts' + +const SOURCE_FRAME_OVERHEAD_BYTES = 4_096 + +/** Limits and declared topics shared by both source transports. */ +export interface InspectorSourceBufferOptions { + readonly topics: readonly string[] + readonly maxQueuedRecords: number + readonly maxQueuedBytes: number + readonly maxRecordsPerFrame: number + readonly maxFrameBytes: number +} + +interface QueuedRecord { + sequence: number + readonly bytes: number + readonly record: InspectorRecordInput +} + +/** + * Owns retained state, queued events, and source-local sequencing independently + * of whether frames travel over MessagePort or WebSocket. + */ +export class InspectorSourceBuffer { + private readonly queue: QueuedRecord[] = [] + private readonly state = new Map() + private queuedBytes = 0 + private nextSequence = 1 + private expectedSequence = 1 + + constructor(private readonly options: InspectorSourceBufferOptions) {} + + /** Whether at least one observation is waiting for transport. */ + get hasPending(): boolean { + return this.queue.length > 0 + } + + /** + * Validate and enqueue one observation, dropping the oldest prefix as needed. + * @param topic - Declared domain topic. + * @param payload - Lossless JSON payload. + * @param monotonicMs - Finite source-clock timestamp. + */ + publish(topic: string, payload: InspectorJsonValue, monotonicMs: number): void { + this.enqueue(this.record(topic, payload, monotonicMs)) + } + + /** + * Replace one retained topic and enqueue the same observation for live delivery. + * @param topic - Declared state topic. + * @param payload - Lossless JSON payload retained for replacement frames. + * @param monotonicMs - Finite source-clock timestamp. + */ + setState(topic: string, payload: InspectorJsonValue, monotonicMs: number): void { + const record = this.record(topic, payload, monotonicMs) + const previous = this.state.get(topic) + this.state.set(topic, record) + if (!this.stateFits()) { + if (previous === undefined) this.state.delete(topic) + else this.state.set(topic, previous) + throw new Error('inspector: source state exceeds the source-frame byte limit') + } + this.enqueue(record) + } + + /** + * Build a complete state replacement and absorb every preceding queue drop. + * @param sourceId - Logical source identity. + * @param generation - Current transport generation. + * @returns A replacement frame whose sequence is the next append position. + */ + replacement(sourceId: InspectorSourceId, generation: InspectorSourceGeneration): SourceReplaceFrame { + const nextSequence = this.queue[0]?.sequence ?? this.nextSequence + this.expectedSequence = nextSequence + return { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'source/replace', + sourceId, + generation, + nextSequence, + records: [...this.state.values()], + } + } + + /** + * Remove and sequence the next transport-sized observation batch. + * @param sourceId - Logical source identity. + * @param generation - Current transport generation. + * @returns The next append frame, or `undefined` when the queue is empty. + */ + takeBatch(sourceId: InspectorSourceId, generation: InspectorSourceGeneration): SourceAppendFrame | undefined { + if (this.queue.length === 0) return undefined + const batch: QueuedRecord[] = [] + let batchBytes = SOURCE_FRAME_OVERHEAD_BYTES + const first = this.queue[0] + if (first === undefined) throw new Error('inspector: non-empty source queue has no first record') + while (batch.length < this.options.maxRecordsPerFrame && this.queue.length > 0) { + const candidate = this.queue[0] + if (candidate === undefined) break + if (candidate.sequence !== first.sequence + batch.length) break + if (batch.length > 0 && batchBytes + candidate.bytes > this.options.maxFrameBytes) break + this.queue.shift() + batch.push(candidate) + batchBytes += candidate.bytes + } + this.queuedBytes -= batch.reduce((sum, item) => sum + item.bytes, 0) + const firstSequence = first.sequence + const frame: SourceAppendFrame = { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'source/append', + sourceId, + generation, + firstSequence, + droppedBefore: firstSequence - this.expectedSequence, + records: batch.map(item => item.record), + } + this.expectedSequence = firstSequence + frame.records.length + return frame + } + + private record(topic: string, payload: InspectorJsonValue, monotonicMs: number): InspectorRecordInput { + if (topic.length === 0 || topic.length > 128) { + throw new Error('inspector: topic must contain 1 to 128 characters') + } + if (!this.options.topics.includes('*') && !this.options.topics.includes(topic)) { + throw new Error(`inspector: source does not declare topic ${JSON.stringify(topic)}`) + } + if (!isJsonValue(payload)) throw new Error('inspector: source payload must be lossless JSON data') + if (!Number.isFinite(monotonicMs)) throw new Error('inspector: monotonicMs must be finite') + return { monotonicMs, topic, payload } + } + + private enqueue(record: InspectorRecordInput): void { + const bytes = jsonByteLength(record as unknown as InspectorJsonValue) + const sequence = this.nextSequence++ + if (bytes + SOURCE_FRAME_OVERHEAD_BYTES > this.options.maxFrameBytes) { + return + } + this.queue.push({ sequence, bytes, record }) + this.queuedBytes += bytes + while (this.queue.length > this.options.maxQueuedRecords || this.queuedBytes > this.options.maxQueuedBytes) { + const dropped = this.queue.shift() + if (dropped === undefined) break + this.queuedBytes -= dropped.bytes + } + } + + private stateFits(): boolean { + return jsonByteLength([...this.state.values()] as unknown as InspectorJsonValue) + SOURCE_FRAME_OVERHEAD_BYTES + <= this.options.maxFrameBytes + } +} diff --git a/packages/experimental/inspector/src/shared/bridge/codec.ts b/packages/experimental/inspector/src/shared/bridge/codec.ts new file mode 100644 index 0000000000..b32b380b1d --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/codec.ts @@ -0,0 +1,4 @@ +/** Bridge-facing exports for lossless JSON values and common wire validators. */ + +export * from '../json.ts' +export * from '../validation.ts' diff --git a/packages/experimental/inspector/src/shared/bridge/control-codec.ts b/packages/experimental/inspector/src/shared/bridge/control-codec.ts new file mode 100644 index 0000000000..95f307c312 --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/control-codec.ts @@ -0,0 +1,153 @@ +/** Exact decoders for Host, Worker, and injected Client lifecycle values. */ + +import type { + InspectorClientBootstrap, + InspectorHostControl, + InspectorWorkerConfig, + InspectorWorkerControl, +} from './messages/control.ts' +import { isPlainObject } from '../json.ts' +import { exactKeys, exactObject } from '../validation.ts' + +/** + * Decode the structured-cloned Worker configuration. + * @param value - Untrusted workerData config value. + * @returns The validated Worker configuration. + */ +export function parseInspectorWorkerConfig(value: unknown): InspectorWorkerConfig { + const record = exactObject(value, [ + 'host', 'startPort', 'targetId', 'clientToken', 'clientOrigins', 'maxSourceFrameBytes', + 'maxSourceRecordsPerFrame', 'maxRetainedRequests', 'maxJournalBytes', 'clientRuntimeTimeoutMs', 'maxCordisNodes', + 'maxDisconnectedCordisTrees', 'maxClientSourceBytes', + ], 'Worker config') + if (record.host !== '127.0.0.1') throw new Error('inspector protocol: Worker host must be 127.0.0.1') + if (typeof record.targetId !== 'string' || record.targetId.length === 0) { + throw new Error('inspector protocol: Worker targetId must be a non-empty string') + } + if (typeof record.clientToken !== 'string' || record.clientToken.length === 0) { + throw new Error('inspector protocol: Worker clientToken must be a non-empty string') + } + if (!Array.isArray(record.clientOrigins) || !record.clientOrigins.every(origin => typeof origin === 'string')) { + throw new Error('inspector protocol: Worker clientOrigins must be strings') + } + const startPort = natural(record.startPort, 'startPort', true) + if (startPort > 65_535) throw new Error('inspector protocol: Worker startPort must not exceed 65535') + return { + host: record.host, + startPort, + targetId: record.targetId, + clientToken: record.clientToken, + clientOrigins: record.clientOrigins, + maxSourceFrameBytes: natural(record.maxSourceFrameBytes, 'maxSourceFrameBytes'), + maxSourceRecordsPerFrame: natural(record.maxSourceRecordsPerFrame, 'maxSourceRecordsPerFrame'), + maxRetainedRequests: natural(record.maxRetainedRequests, 'maxRetainedRequests'), + maxJournalBytes: natural(record.maxJournalBytes, 'maxJournalBytes'), + clientRuntimeTimeoutMs: natural(record.clientRuntimeTimeoutMs, 'clientRuntimeTimeoutMs'), + maxClientSourceBytes: natural(record.maxClientSourceBytes, 'maxClientSourceBytes'), + maxCordisNodes: natural(record.maxCordisNodes, 'maxCordisNodes'), + maxDisconnectedCordisTrees: natural(record.maxDisconnectedCordisTrees, 'maxDisconnectedCordisTrees', true), + } +} + +/** + * Decode one Host-to-Worker lifecycle command. + * @param value - Untrusted control message. + * @returns The validated Host command. + */ +export function parseInspectorHostControl(value: unknown): InspectorHostControl { + const record = exactObject(value, ['type'], 'Host control message') + if (record.type !== 'shutdown') throw new Error('inspector protocol: unknown Host control message') + return { type: 'shutdown' } +} + +/** + * Decode one Worker-to-Host lifecycle event. + * @param value - Untrusted control message. + * @returns The validated Worker event. + */ +export function parseInspectorWorkerControl(value: unknown): InspectorWorkerControl { + const record = exactObjectByType(value, 'Worker control message') + switch (record.type) { + case 'ready': + exactKeys(record, ['type', 'host', 'port', 'targetId'], 'Worker ready message') + if (typeof record.host !== 'string' || typeof record.targetId !== 'string') { + throw new Error('inspector protocol: invalid Worker ready identity') + } + return { + type: 'ready', + host: record.host, + port: natural(record.port, 'port', true), + targetId: record.targetId, + } + case 'failure': + exactKeys(record, ['type', 'message'], 'Worker failure message') + if (typeof record.message !== 'string') throw new Error('inspector protocol: invalid Worker failure') + return { type: 'failure', message: record.message } + case 'stopped': + exactKeys(record, ['type'], 'Worker stopped message') + return { type: 'stopped' } + default: + throw new Error('inspector protocol: unknown Worker control message') + } +} + +/** + * Decode bootstrap data injected into the browser global. + * @param value - Untrusted injected value. + * @returns The validated Client bootstrap. + */ +export function parseInspectorClientBootstrap(value: unknown): InspectorClientBootstrap { + const record = exactObject(value, [ + 'endpoint', 'protocol', 'maxQueuedRecords', 'maxQueuedBytes', 'maxRecordsPerFrame', 'maxFrameBytes', + 'reconnectBaseMs', 'reconnectMaxMs', 'queryTimeoutMs', 'maxRuntimeObjectsPerSession', + 'maxRuntimePropertiesPerResult', 'maxCordisNodes', 'maxClientSourceBytes', + ], 'Client bootstrap') + if (typeof record.endpoint !== 'string' || typeof record.protocol !== 'string') { + throw new Error('inspector protocol: Client bootstrap endpoint and protocol must be strings') + } + let endpoint: URL + try { + endpoint = new URL(record.endpoint) + } catch { + throw new Error('inspector protocol: Client bootstrap endpoint must be an absolute URL') + } + if (endpoint.protocol !== 'ws:' || endpoint.hostname !== '127.0.0.1') { + throw new Error('inspector protocol: Client bootstrap endpoint must use ws on 127.0.0.1') + } + if (record.protocol.length === 0 || record.protocol.length > 256) { + throw new Error('inspector protocol: Client bootstrap protocol must contain 1 to 256 characters') + } + const bootstrap: InspectorClientBootstrap = { + endpoint: record.endpoint, + protocol: record.protocol, + maxQueuedRecords: natural(record.maxQueuedRecords, 'maxQueuedRecords'), + maxQueuedBytes: natural(record.maxQueuedBytes, 'maxQueuedBytes'), + maxRecordsPerFrame: natural(record.maxRecordsPerFrame, 'maxRecordsPerFrame'), + maxFrameBytes: natural(record.maxFrameBytes, 'maxFrameBytes'), + reconnectBaseMs: natural(record.reconnectBaseMs, 'reconnectBaseMs'), + reconnectMaxMs: natural(record.reconnectMaxMs, 'reconnectMaxMs'), + queryTimeoutMs: natural(record.queryTimeoutMs, 'queryTimeoutMs'), + maxRuntimeObjectsPerSession: natural(record.maxRuntimeObjectsPerSession, 'maxRuntimeObjectsPerSession'), + maxRuntimePropertiesPerResult: natural(record.maxRuntimePropertiesPerResult, 'maxRuntimePropertiesPerResult'), + maxClientSourceBytes: natural(record.maxClientSourceBytes, 'maxClientSourceBytes'), + maxCordisNodes: natural(record.maxCordisNodes, 'maxCordisNodes'), + } + if (bootstrap.reconnectMaxMs < bootstrap.reconnectBaseMs) { + throw new Error('inspector protocol: reconnectMaxMs must be at least reconnectBaseMs') + } + return bootstrap +} + +function exactObjectByType(value: unknown, label: string): Record { + if (!isPlainObject(value) || typeof value.type !== 'string') { + throw new Error(`inspector protocol: ${label} must have a type`) + } + return value +} + +function natural(value: unknown, label: string, zero = false): number { + if (!Number.isSafeInteger(value) || (value as number) < (zero ? 0 : 1)) { + throw new Error(`inspector protocol: ${label} must be ${zero ? 'a non-negative' : 'a positive'} safe integer`) + } + return value as number +} diff --git a/packages/experimental/inspector/src/shared/bridge/ids.ts b/packages/experimental/inspector/src/shared/bridge/ids.ts new file mode 100644 index 0000000000..7052c2fe72 --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/ids.ts @@ -0,0 +1,27 @@ +/** Opaque identifiers owned by the cross-realm Inspector bridge. */ + +import type { InspectorId } from '../identity.ts' + +export { inspectorId } from '../identity.ts' +export type { InspectorId } from '../identity.ts' + +/** Stable identity of one logical observation source. */ +export type InspectorSourceId = InspectorId<'InspectorSourceId'> + +/** Identity of one source connection generation. */ +export type InspectorSourceGeneration = InspectorId<'InspectorSourceGeneration'> + +/** Identity of one DevTools connection's Client Runtime state. */ +export type ClientRuntimeSessionId = InspectorId<'ClientRuntimeSessionId'> + +/** Identity of one in-flight Worker-to-Client Runtime operation. */ +export type ClientRuntimeRequestId = InspectorId<'ClientRuntimeRequestId'> + +/** Identity of one DevTools connection's Client source catalog session. */ +export type ClientSourceSessionId = InspectorId<'ClientSourceSessionId'> + +/** Identity of one in-flight Worker-to-Client source operation. */ +export type ClientSourceRequestId = InspectorId<'ClientSourceRequestId'> + +/** Opaque reference to an object retained inside one Client Runtime session. */ +export type ClientRemoteObjectHandle = InspectorId<'ClientRemoteObjectHandle'> diff --git a/packages/experimental/inspector/src/shared/bridge/messages/control.ts b/packages/experimental/inspector/src/shared/bridge/messages/control.ts new file mode 100644 index 0000000000..9091168d02 --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/messages/control.ts @@ -0,0 +1,72 @@ +/** Host-to-Worker lifecycle messages and Worker readiness results. */ + +/** Fully resolved Worker configuration. */ +export interface InspectorWorkerConfig { + readonly host: '127.0.0.1' + /** First port to bind; zero delegates selection to the operating system. */ + readonly startPort: number + readonly targetId: string + readonly clientToken: string + readonly clientOrigins: readonly string[] + readonly maxSourceFrameBytes: number + readonly maxSourceRecordsPerFrame: number + readonly maxRetainedRequests: number + readonly maxJournalBytes: number + readonly clientRuntimeTimeoutMs: number + readonly maxClientSourceBytes: number + readonly maxCordisNodes: number + readonly maxDisconnectedCordisTrees: number +} + +/** Structured-clone payload used to start the Inspector Worker. */ +export interface InspectorWorkerBoot { + readonly config: InspectorWorkerConfig + readonly hostSourcePort: Port +} + +/** Host request to stop accepting traffic and close every Worker-owned resource. */ +export interface InspectorWorkerShutdown { + readonly type: 'shutdown' +} + +/** Every control message sent from Host to Worker after boot. */ +export type InspectorHostControl = InspectorWorkerShutdown + +/** Worker endpoint readiness. */ +export interface InspectorWorkerReady { + readonly type: 'ready' + readonly host: string + readonly port: number + readonly targetId: string +} + +/** Worker startup or runtime failure. */ +export interface InspectorWorkerFailure { + readonly type: 'failure' + readonly message: string +} + +/** Worker completed graceful shutdown. */ +export interface InspectorWorkerStopped { + readonly type: 'stopped' +} + +/** Every control message sent from Worker to Host. */ +export type InspectorWorkerControl = InspectorWorkerReady | InspectorWorkerFailure | InspectorWorkerStopped + +/** Browser bootstrap injected by the Host plugin. */ +export interface InspectorClientBootstrap { + readonly endpoint: string + readonly protocol: string + readonly maxQueuedRecords: number + readonly maxQueuedBytes: number + readonly maxRecordsPerFrame: number + readonly maxFrameBytes: number + readonly reconnectBaseMs: number + readonly reconnectMaxMs: number + readonly queryTimeoutMs: number + readonly maxRuntimeObjectsPerSession: number + readonly maxRuntimePropertiesPerResult: number + readonly maxClientSourceBytes: number + readonly maxCordisNodes: number +} diff --git a/packages/experimental/inspector/src/shared/bridge/messages/observation.ts b/packages/experimental/inspector/src/shared/bridge/messages/observation.ts new file mode 100644 index 0000000000..1127eb0d85 --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/messages/observation.ts @@ -0,0 +1,363 @@ +/** Versioned source lifecycle, observation, and extension frames shared by both carriers. */ + +import { inspectorId, type InspectorSourceGeneration, type InspectorSourceId } from '../ids.ts' +import { isJsonValue, isPlainObject, type InspectorJsonValue } from '../../json.ts' +import { exactKeys } from '../../validation.ts' +import { INSPECTOR_PROTOCOL_VERSION } from '../version.ts' +import { + parseClientConsoleCapability, + parseClientConsoleControlFrame, + parseClientConsoleEventFrame, + parseClientRuntimeCapability, + parseClientRuntimeRequestFrame, + parseClientRuntimeResponseFrame, + parseClientRuntimeSessionClosedFrame, + type ClientConsoleCapability, + type ClientConsoleDisableFrame, + type ClientConsoleEnableFrame, + type ClientConsoleEventFrame, + type ClientRuntimeCapability, + type ClientRuntimeRequestFrame, + type ClientRuntimeResponseFrame, + type ClientRuntimeSessionClosedFrame, +} from './runtime/index.ts' +import { + parseClientSourceRequestFrame, + parseClientSourceResponseFrame, + parseClientSourceSessionClosedFrame, + parseClientSourcesCapability, + type ClientSourceRequestFrame, + type ClientSourceResponseFrame, + type ClientSourceSessionClosedFrame, + type ClientSourcesCapability, +} from './sources/index.ts' + +export { INSPECTOR_PROTOCOL_VERSION } from '../version.ts' + +/** Realm producing observations. */ +export type InspectorSourceKind = 'host' | 'client' + +/** Optional protocols implemented by one source generation. */ +export type InspectorSourceCapability = ClientRuntimeCapability | ClientConsoleCapability | ClientSourcesCapability + +/** One logical source and connection generation. */ +export interface InspectorSourceDescriptor { + /** Producer identity retained across transport reconnects. */ + readonly sourceId: InspectorSourceId + /** One transport admission, replaced on every reconnect. */ + readonly generation: InspectorSourceGeneration + readonly kind: InspectorSourceKind + readonly label: string + readonly timeOriginMs: number + readonly capabilities: readonly InspectorSourceCapability[] +} + +/** One domain-owned observation before its sequence is assigned. */ +export interface InspectorRecordInput { + readonly monotonicMs: number + readonly topic: string + readonly payload: InspectorJsonValue +} + +/** Initial source handshake. */ +export interface SourceOpenFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'source/open' + readonly source: InspectorSourceDescriptor + readonly topics: readonly string[] +} + +/** Replace one source's current state after opening or resynchronization. */ +export interface SourceReplaceFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'source/replace' + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration + readonly nextSequence: number + readonly records: readonly InspectorRecordInput[] +} + +/** Append one contiguous observation batch. */ +export interface SourceAppendFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'source/append' + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration + readonly firstSequence: number + readonly droppedBefore: number + readonly records: readonly InspectorRecordInput[] +} + +/** Clean source closure. */ +export interface SourceCloseFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'source/close' + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration +} + +/** Every source-to-Worker frame. */ +export type SourceToWorkerFrame = + | SourceOpenFrame + | SourceReplaceFrame + | SourceAppendFrame + | SourceCloseFrame + | ClientConsoleEventFrame + | ClientRuntimeResponseFrame + | ClientSourceResponseFrame + +/** Worker acceptance of one source generation. */ +export interface SourceAcceptedFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'source/accepted' + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration +} + +/** Worker request for a complete source-state replacement. */ +export interface SourceResnapshotFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'source/resnapshot' + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration + readonly expectedSequence: number + readonly reason: string +} + +/** Rejection of one malformed or incompatible source connection. */ +export interface SourceRejectedFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'source/rejected' + readonly code: 'invalid-frame' | 'version-mismatch' | 'unauthorized' + readonly message: string +} + +/** Every Worker-to-source control frame. */ +export type WorkerToSourceFrame = + | SourceAcceptedFrame + | SourceResnapshotFrame + | SourceRejectedFrame + | ClientConsoleEnableFrame + | ClientConsoleDisableFrame + | ClientRuntimeRequestFrame + | ClientRuntimeSessionClosedFrame + | ClientSourceRequestFrame + | ClientSourceSessionClosedFrame + +/** + * Parse and rebuild one Worker control frame received by a source. + * @param value - Untrusted decoded wire value. + * @returns The validated Worker-to-source frame. + */ +export function parseWorkerSourceFrame(value: unknown): WorkerToSourceFrame { + if (!isJsonValue(value) + || !isPlainObject(value) + || value.v !== INSPECTOR_PROTOCOL_VERSION + || typeof value.t !== 'string') { + throw new Error('inspector protocol: invalid Worker source frame') + } + if (value.t === 'source/rejected') { + exactKeys(value, ['v', 't', 'code', 'message'], 'source/rejected frame') + if ((value.code !== 'invalid-frame' && value.code !== 'version-mismatch' && value.code !== 'unauthorized') + || typeof value.message !== 'string') { + throw new Error('inspector protocol: invalid source/rejected frame') + } + return { v: INSPECTOR_PROTOCOL_VERSION, t: 'source/rejected', code: value.code, message: value.message } + } + if (value.t === 'client-runtime/request') return parseClientRuntimeRequestFrame(value) + if (value.t === 'client-runtime/session-closed') return parseClientRuntimeSessionClosedFrame(value) + if (value.t === 'client-sources/request') return parseClientSourceRequestFrame(value) + if (value.t === 'client-sources/session-closed') return parseClientSourceSessionClosedFrame(value) + if (value.t === 'client-console/enable' || value.t === 'client-console/disable') { + return parseClientConsoleControlFrame(value) + } + const common = { + v: INSPECTOR_PROTOCOL_VERSION, + sourceId: sourceId(value.sourceId), + generation: generation(value.generation), + } as const + if (value.t === 'source/accepted') { + exactKeys(value, ['v', 't', 'sourceId', 'generation'], 'source/accepted frame') + return { ...common, t: 'source/accepted' } + } + if (value.t === 'source/resnapshot' + && typeof value.reason === 'string') { + exactKeys(value, ['v', 't', 'sourceId', 'generation', 'expectedSequence', 'reason'], 'source/resnapshot frame') + return { + ...common, + t: 'source/resnapshot', + expectedSequence: natural(value.expectedSequence, 'expectedSequence'), + reason: value.reason, + } + } + throw new Error(`inspector protocol: unknown Worker source frame ${JSON.stringify(value.t)}`) +} + +/** + * Parse and rebuild one source frame received at a process or network boundary. + * @param value - Untrusted decoded wire value. + * @param maxRecords - Maximum records admitted in one frame. + * @returns The validated source-to-Worker frame. + */ +export function parseSourceFrame(value: unknown, maxRecords: number): SourceToWorkerFrame { + if (!isJsonValue(value) || !isPlainObject(value)) { + throw new Error('inspector protocol: source frame must be a lossless JSON object') + } + if (value.v !== INSPECTOR_PROTOCOL_VERSION) { + throw new Error(`inspector protocol: unsupported version ${JSON.stringify(value.v)}`) + } + switch (value.t) { + case 'source/open': + return parseOpen(value) + case 'source/replace': + return parseRecordsFrame(value, maxRecords, true) + case 'source/append': + return parseRecordsFrame(value, maxRecords, false) + case 'source/close': + exactKeys(value, ['v', 't', 'sourceId', 'generation'], 'source/close frame') + return { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'source/close', + sourceId: sourceId(value.sourceId), + generation: generation(value.generation), + } + case 'client-runtime/response': + return parseClientRuntimeResponseFrame(value) + case 'client-console/event': + return parseClientConsoleEventFrame(value) + case 'client-sources/response': + return parseClientSourceResponseFrame(value) + default: + throw new Error(`inspector protocol: unknown source frame ${JSON.stringify(value.t)}`) + } +} + +function parseOpen(value: Record): SourceOpenFrame { + exactKeys(value, ['v', 't', 'source', 'topics'], 'source/open frame') + if (!isPlainObject(value.source) || !Array.isArray(value.topics)) { + throw new Error('inspector protocol: source/open needs source and topics') + } + const source = value.source + exactKeys(source, ['sourceId', 'generation', 'kind', 'label', 'timeOriginMs', 'capabilities'], 'source descriptor') + const kind = source.kind + if (kind !== 'host' && kind !== 'client') throw new Error('inspector protocol: invalid source kind') + if (typeof source.label !== 'string' || source.label.length === 0 || source.label.length > 256) { + throw new Error('inspector protocol: source label must contain 1 to 256 characters') + } + if (typeof source.timeOriginMs !== 'number' || !Number.isFinite(source.timeOriginMs)) { + throw new Error('inspector protocol: source timeOriginMs must be finite') + } + if (!Array.isArray(source.capabilities)) { + throw new Error('inspector protocol: source capabilities must be an array') + } + const capabilities = source.capabilities.map(parseSourceCapability) + const capabilityTypes = new Set() + for (const capability of capabilities) { + if (capabilityTypes.has(capability.type)) { + throw new Error(`inspector protocol: source declares ${capability.type} more than once`) + } + capabilityTypes.add(capability.type) + } + if (kind !== 'client' && capabilities.length > 0) { + throw new Error('inspector protocol: Host sources cannot declare Client capabilities') + } + const topics = value.topics.map((topic) => { + if (typeof topic !== 'string' || topic.length === 0 || topic.length > 128) { + throw new Error('inspector protocol: every source topic must contain 1 to 128 characters') + } + return topic + }) + return { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'source/open', + source: { + sourceId: sourceId(source.sourceId), + generation: generation(source.generation), + kind, + label: source.label, + timeOriginMs: source.timeOriginMs, + capabilities, + }, + topics, + } +} + +function parseSourceCapability(value: unknown): InspectorSourceCapability { + if (!isPlainObject(value) || typeof value.type !== 'string') { + throw new Error('inspector protocol: source capability must have a type') + } + switch (value.type) { + case 'client-runtime': return parseClientRuntimeCapability(value) + case 'client-console': return parseClientConsoleCapability(value) + case 'client-sources': return parseClientSourcesCapability(value) + default: throw new Error(`inspector protocol: unknown source capability ${JSON.stringify(value.type)}`) + } +} + +function parseRecordsFrame( + value: Record, + maxRecords: number, + replace: boolean, +): SourceReplaceFrame | SourceAppendFrame { + exactKeys( + value, + replace + ? ['v', 't', 'sourceId', 'generation', 'nextSequence', 'records'] + : ['v', 't', 'sourceId', 'generation', 'firstSequence', 'droppedBefore', 'records'], + replace ? 'source/replace frame' : 'source/append frame', + ) + if (!Array.isArray(value.records) || value.records.length > maxRecords) { + throw new Error(`inspector protocol: source batch exceeds ${String(maxRecords)} records`) + } + const records = value.records.map(parseRecord) + const common = { + v: INSPECTOR_PROTOCOL_VERSION, + sourceId: sourceId(value.sourceId), + generation: generation(value.generation), + records, + } as const + if (replace) { + return { + ...common, + t: 'source/replace', + nextSequence: natural(value.nextSequence, 'nextSequence'), + } + } + return { + ...common, + t: 'source/append', + firstSequence: natural(value.firstSequence, 'firstSequence'), + droppedBefore: natural(value.droppedBefore, 'droppedBefore'), + } +} + +function parseRecord(value: unknown): InspectorRecordInput { + if (!isPlainObject(value) + || typeof value.monotonicMs !== 'number' + || !Number.isFinite(value.monotonicMs) + || typeof value.topic !== 'string' + || value.topic.length === 0 + || value.topic.length > 128 + || !isJsonValue(value.payload)) { + throw new Error('inspector protocol: invalid observation record') + } + exactKeys(value, ['monotonicMs', 'topic', 'payload'], 'observation record') + return { monotonicMs: value.monotonicMs, topic: value.topic, payload: value.payload } +} + +function sourceId(value: unknown): InspectorSourceId { + if (typeof value !== 'string') throw new Error('inspector protocol: sourceId must be a string') + return inspectorId<'InspectorSourceId'>(value, 'sourceId') +} + +function generation(value: unknown): InspectorSourceGeneration { + if (typeof value !== 'string') throw new Error('inspector protocol: generation must be a string') + return inspectorId<'InspectorSourceGeneration'>(value, 'generation') +} + +function natural(value: unknown, label: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new Error(`inspector protocol: ${label} must be a non-negative safe integer`) + } + return value as number +} diff --git a/packages/experimental/inspector/src/shared/bridge/messages/runtime/command-codec.ts b/packages/experimental/inspector/src/shared/bridge/messages/runtime/command-codec.ts new file mode 100644 index 0000000000..d4cb579616 --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/messages/runtime/command-codec.ts @@ -0,0 +1,134 @@ +/** Exact wire decoder for Client Runtime commands. */ + +import { isJsonValue, isPlainObject } from '../../../json.ts' +import { exactKeys, optionalBoolean, optionalNonNegativeNumber, optionalString, wireId } from '../../../validation.ts' +import type { ClientCallArgument, ClientRuntimeCallFunctionCommand, ClientRuntimeCommand } from './commands.ts' + +/** + * Parse and rebuild one Runtime command before it enters the Client realm. + * @param value - Untrusted command value. + * @returns The validated command union member. + */ +export function parseClientRuntimeCommand(value: unknown): ClientRuntimeCommand { + if (!isPlainObject(value) || typeof value.op !== 'string') { + throw new Error('inspector protocol: Client Runtime command must have an op') + } + switch (value.op) { + case 'evaluate': { + exactKeys(value, [ + 'op', 'expression', 'objectGroup', 'includeCommandLineAPI', 'silent', 'returnByValue', + 'generatePreview', 'userGesture', 'awaitPromise', 'disableBreaks', 'replMode', + 'allowUnsafeEvalBlockedByCSP', 'timeoutMs', + ], 'evaluate command') + if (typeof value.expression !== 'string') throw new Error('inspector protocol: evaluate expression must be a string') + return { + op: 'evaluate', + expression: value.expression, + ...optionalString(value, 'objectGroup'), + ...optionalBoolean(value, 'includeCommandLineAPI'), + ...optionalBoolean(value, 'silent'), + ...optionalBoolean(value, 'returnByValue'), + ...optionalBoolean(value, 'generatePreview'), + ...optionalBoolean(value, 'userGesture'), + ...optionalBoolean(value, 'awaitPromise'), + ...optionalBoolean(value, 'disableBreaks'), + ...optionalBoolean(value, 'replMode'), + ...optionalBoolean(value, 'allowUnsafeEvalBlockedByCSP'), + ...optionalNonNegativeNumber(value, 'timeoutMs'), + } + } + case 'get-properties': + exactKeys(value, [ + 'op', 'handle', 'ownProperties', 'accessorPropertiesOnly', 'generatePreview', 'nonIndexedPropertiesOnly', + ], 'get-properties command') + return { + op: 'get-properties', + handle: wireId<'ClientRemoteObjectHandle'>(value.handle, 'handle'), + ...optionalBoolean(value, 'ownProperties'), + ...optionalBoolean(value, 'accessorPropertiesOnly'), + ...optionalBoolean(value, 'generatePreview'), + ...optionalBoolean(value, 'nonIndexedPropertiesOnly'), + } + case 'call-function': + return parseCallFunction(value) + case 'await-promise': + exactKeys(value, ['op', 'promise', 'returnByValue', 'generatePreview'], 'await-promise command') + return { + op: 'await-promise', + promise: wireId<'ClientRemoteObjectHandle'>(value.promise, 'promise'), + ...optionalBoolean(value, 'returnByValue'), + ...optionalBoolean(value, 'generatePreview'), + } + case 'release-object': + exactKeys(value, ['op', 'handle'], 'release-object command') + return { + op: 'release-object', + handle: wireId<'ClientRemoteObjectHandle'>(value.handle, 'handle'), + } + case 'release-object-group': + exactKeys(value, ['op', 'objectGroup'], 'release-object-group command') + if (typeof value.objectGroup !== 'string') throw new Error('inspector protocol: objectGroup must be a string') + return { op: 'release-object-group', objectGroup: value.objectGroup } + case 'global-lexical-scope-names': + exactKeys(value, ['op'], 'global-lexical-scope-names command') + return { op: 'global-lexical-scope-names' } + default: + throw new Error(`inspector protocol: unknown Client Runtime command ${JSON.stringify(value.op)}`) + } +} + +function parseCallFunction(value: Record): ClientRuntimeCallFunctionCommand { + exactKeys(value, [ + 'op', 'functionDeclaration', 'receiver', 'arguments', 'objectGroup', 'silent', 'returnByValue', + 'generatePreview', 'userGesture', 'awaitPromise', + ], 'call-function command') + if (typeof value.functionDeclaration !== 'string') { + throw new Error('inspector protocol: functionDeclaration must be a string') + } + let args: readonly ClientCallArgument[] | undefined + if (value.arguments !== undefined) { + if (!Array.isArray(value.arguments)) throw new Error('inspector protocol: call arguments must be an array') + args = value.arguments.map(parseCallArgument) + } + return { + op: 'call-function', + functionDeclaration: value.functionDeclaration, + ...(value.receiver === undefined + ? {} + : { receiver: wireId<'ClientRemoteObjectHandle'>(value.receiver, 'receiver') }), + ...(args === undefined ? {} : { arguments: args }), + ...optionalString(value, 'objectGroup'), + ...optionalBoolean(value, 'silent'), + ...optionalBoolean(value, 'returnByValue'), + ...optionalBoolean(value, 'generatePreview'), + ...optionalBoolean(value, 'userGesture'), + ...optionalBoolean(value, 'awaitPromise'), + } +} + +function parseCallArgument(value: unknown): ClientCallArgument { + if (!isPlainObject(value) || typeof value.kind !== 'string') { + throw new Error('inspector protocol: invalid Client Runtime call argument') + } + switch (value.kind) { + case 'value': + exactKeys(value, ['kind', 'value'], 'value call argument') + if (!isJsonValue(value.value)) throw new Error('inspector protocol: call argument value must be JSON') + return { kind: 'value', value: value.value } + case 'unserializable': + exactKeys(value, ['kind', 'value'], 'unserializable call argument') + if (typeof value.value !== 'string') throw new Error('inspector protocol: unserializable argument must be a string') + return { kind: 'unserializable', value: value.value } + case 'object': + exactKeys(value, ['kind', 'handle'], 'object call argument') + return { + kind: 'object', + handle: wireId<'ClientRemoteObjectHandle'>(value.handle, 'handle'), + } + case 'undefined': + exactKeys(value, ['kind'], 'undefined call argument') + return { kind: 'undefined' } + default: + throw new Error(`inspector protocol: unknown call argument ${JSON.stringify(value.kind)}`) + } +} diff --git a/packages/experimental/inspector/src/shared/bridge/messages/runtime/commands.ts b/packages/experimental/inspector/src/shared/bridge/messages/runtime/commands.ts new file mode 100644 index 0000000000..4e16b58129 --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/messages/runtime/commands.ts @@ -0,0 +1,101 @@ +/** Closed command/result protocol for Runtime operations executed by a Client. */ + +import type { ClientRemoteObjectHandle } from '../../ids.ts' +import type { + RuntimeExceptionDetails, + RuntimeInternalPropertyDescriptor, + RuntimeCallArgument, + RuntimeAwaitPromiseRequest, + RuntimeCallFunctionRequest, + RuntimeCompletion, + RuntimeEvaluateRequest, + RuntimeGetPropertiesRequest, + RuntimePropertyDescriptor, + RuntimeRemoteObject, +} from '../../../cdp/index.ts' + +/** Runtime object serialized with one Client-session handle when retained. */ +export type ClientRuntimeRemoteObject = RuntimeRemoteObject + +/** Property descriptor whose retained values use Client-session handles. */ +export type ClientRuntimePropertyDescriptor = RuntimePropertyDescriptor + +/** Internal property descriptor whose retained values use Client-session handles. */ +export type ClientRuntimeInternalPropertyDescriptor = RuntimeInternalPropertyDescriptor + +/** Exception details whose retained value uses a Client-session handle. */ +export type ClientRuntimeExceptionDetails = RuntimeExceptionDetails + +/** One argument supplied to a function in the Client realm. */ +export type ClientCallArgument = RuntimeCallArgument + +/** Evaluate source text in the Client global execution context. */ +export interface ClientRuntimeEvaluateCommand extends RuntimeEvaluateRequest { + readonly op: 'evaluate' +} + +/** Enumerate properties of one retained Client object. */ +export interface ClientRuntimeGetPropertiesCommand extends RuntimeGetPropertiesRequest { + readonly op: 'get-properties' +} + +/** Invoke a function declaration with Client-local receivers and arguments. */ +export interface ClientRuntimeCallFunctionCommand extends RuntimeCallFunctionRequest { + readonly op: 'call-function' +} + +/** Await one retained Client promise. */ +export interface ClientRuntimeAwaitPromiseCommand extends RuntimeAwaitPromiseRequest { + readonly op: 'await-promise' +} + +/** Release one retained Client object. */ +export interface ClientRuntimeReleaseObjectCommand { + readonly op: 'release-object' + readonly handle: ClientRemoteObjectHandle +} + +/** Release every Client object retained under one DevTools object group. */ +export interface ClientRuntimeReleaseObjectGroupCommand { + readonly op: 'release-object-group' + readonly objectGroup: string +} + +/** Read names visible in the Client global lexical scope. */ +export interface ClientRuntimeGlobalLexicalScopeNamesCommand { + readonly op: 'global-lexical-scope-names' +} + +/** Closed command set implemented by the Client Runtime transport. */ +export type ClientRuntimeCommand = + | ClientRuntimeEvaluateCommand + | ClientRuntimeGetPropertiesCommand + | ClientRuntimeCallFunctionCommand + | ClientRuntimeAwaitPromiseCommand + | ClientRuntimeReleaseObjectCommand + | ClientRuntimeReleaseObjectGroupCommand + | ClientRuntimeGlobalLexicalScopeNamesCommand + +/** Shared result of evaluation, function calls, and promise awaiting. */ +export type ClientRuntimeCompletion = RuntimeCompletion + +/** Result discriminant mirrors the command and prevents cross-method settlement. */ +export type ClientRuntimeResult = + | { readonly op: 'evaluate'; readonly completion: ClientRuntimeCompletion } + | { + readonly op: 'get-properties' + readonly properties: readonly ClientRuntimePropertyDescriptor[] + readonly internalProperties?: readonly ClientRuntimeInternalPropertyDescriptor[] + readonly exceptionDetails?: ClientRuntimeExceptionDetails + } + | { readonly op: 'call-function'; readonly completion: ClientRuntimeCompletion } + | { readonly op: 'await-promise'; readonly completion: ClientRuntimeCompletion } + | { readonly op: 'release-object' } + | { readonly op: 'release-object-group' } + | { readonly op: 'global-lexical-scope-names'; readonly names: readonly string[] } + +/** Stable transport-level failures distinct from evaluated JavaScript exceptions. */ +export interface ClientRuntimeError { + readonly code: 'invalid-request' | 'object-not-found' | 'unsupported' | 'timeout' | 'result-too-large' | 'internal-error' + readonly message: string +} diff --git a/packages/experimental/inspector/src/shared/bridge/messages/runtime/console-frames.ts b/packages/experimental/inspector/src/shared/bridge/messages/runtime/console-frames.ts new file mode 100644 index 0000000000..211acba8e2 --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/messages/runtime/console-frames.ts @@ -0,0 +1,147 @@ +/** Typed transport for Client Console sessions and events. */ + +import type { ClientRemoteObjectHandle, ClientRuntimeSessionId, InspectorSourceGeneration, InspectorSourceId } from '../../ids.ts' +import { isPlainObject } from '../../../json.ts' +import type { RuntimeConsoleBackendEvent, RuntimeConsoleType } from '../../../cdp/index.ts' +import { exactKeys, exactObject, wireId } from '../../../validation.ts' +import { INSPECTOR_PROTOCOL_VERSION } from '../../version.ts' +import { + parseClientRuntimeExceptionDetails, + parseClientRuntimeRemoteObject, + parseClientRuntimeStackTrace, +} from './value-codec.ts' + +/** Source capability that permits Client Console event forwarding. */ +export interface ClientConsoleCapability { + readonly type: 'client-console' +} + +/** Worker request to start Console observation for one DevTools session. */ +export interface ClientConsoleEnableFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'client-console/enable' + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration + readonly sessionId: ClientRuntimeSessionId +} + +/** Worker request to stop Console observation for one DevTools session. */ +export interface ClientConsoleDisableFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'client-console/disable' + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration + readonly sessionId: ClientRuntimeSessionId +} + +/** Client Console event carrying objects retained for one DevTools session. */ +export interface ClientConsoleEventFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'client-console/event' + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration + readonly sessionId: ClientRuntimeSessionId + readonly event: RuntimeConsoleBackendEvent +} + +/** + * Parse the marker capability for Client Console forwarding. + * @param value - Untrusted capability declaration. + * @returns The validated marker capability. + */ +export function parseClientConsoleCapability(value: unknown): ClientConsoleCapability { + const record = exactObject(value, ['type'], 'Client Console capability') + if (record.type !== 'client-console') throw new Error('inspector protocol: invalid Client Console capability') + return { type: 'client-console' } +} + +/** + * Parse a Worker-to-Client Console lifecycle frame. + * @param value - Untrusted decoded frame. + * @returns A validated enable or disable frame. + */ +export function parseClientConsoleControlFrame( + value: Record, +): ClientConsoleEnableFrame | ClientConsoleDisableFrame { + exactKeys(value, ['v', 't', 'sourceId', 'generation', 'sessionId'], 'Client Console control frame') + if (value.v !== INSPECTOR_PROTOCOL_VERSION + || (value.t !== 'client-console/enable' && value.t !== 'client-console/disable')) { + throw new Error('inspector protocol: invalid Client Console control frame') + } + return { + v: INSPECTOR_PROTOCOL_VERSION, + t: value.t, + sourceId: wireId<'InspectorSourceId'>(value.sourceId, 'sourceId'), + generation: wireId<'InspectorSourceGeneration'>(value.generation, 'generation'), + sessionId: wireId<'ClientRuntimeSessionId'>(value.sessionId, 'sessionId'), + } +} + +/** + * Parse one Client-to-Worker Console event. + * @param value - Untrusted decoded frame. + * @returns A validated Console event frame. + */ +export function parseClientConsoleEventFrame(value: Record): ClientConsoleEventFrame { + exactKeys(value, ['v', 't', 'sourceId', 'generation', 'sessionId', 'event'], 'Client Console event frame') + if (value.v !== INSPECTOR_PROTOCOL_VERSION || value.t !== 'client-console/event') { + throw new Error('inspector protocol: invalid Client Console event envelope') + } + return { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'client-console/event', + sourceId: wireId<'InspectorSourceId'>(value.sourceId, 'sourceId'), + generation: wireId<'InspectorSourceGeneration'>(value.generation, 'generation'), + sessionId: wireId<'ClientRuntimeSessionId'>(value.sessionId, 'sessionId'), + event: parseEvent(value.event), + } +} + +function parseEvent(value: unknown): RuntimeConsoleBackendEvent { + if (!isPlainObject(value) || (value.type !== 'console-api' && value.type !== 'exception')) { + throw new Error('inspector protocol: invalid Client Console event') + } + if (value.type === 'console-api') { + exactKeys(value, ['type', 'event'], 'Client Console API event') + const event = exactObject(value.event, ['type', 'arguments', 'timestamp', 'contextId', 'stackTrace'], 'Console API event') + if (!CONSOLE_TYPES.has(event.type as RuntimeConsoleType) + || !Array.isArray(event.arguments) + || typeof event.timestamp !== 'number' + || !Number.isFinite(event.timestamp)) { + throw new Error('inspector protocol: invalid Console API event') + } + return { + type: 'console-api', + event: { + type: event.type as RuntimeConsoleType, + arguments: event.arguments.map(parseClientRuntimeRemoteObject), + timestamp: event.timestamp, + ...(event.contextId === undefined ? {} : { contextId: integer(event.contextId, 'contextId') }), + ...(event.stackTrace === undefined ? {} : { stackTrace: parseClientRuntimeStackTrace(event.stackTrace) }), + }, + } + } + exactKeys(value, ['type', 'event'], 'Client exception event') + const event = exactObject(value.event, ['timestamp', 'contextId', 'details'], 'Client exception event payload') + if (typeof event.timestamp !== 'number' || !Number.isFinite(event.timestamp)) { + throw new Error('inspector protocol: invalid Client exception timestamp') + } + return { + type: 'exception', + event: { + timestamp: event.timestamp, + ...(event.contextId === undefined ? {} : { contextId: integer(event.contextId, 'contextId') }), + details: parseClientRuntimeExceptionDetails(event.details), + }, + } +} + +function integer(value: unknown, label: string): number { + if (!Number.isSafeInteger(value)) throw new Error(`inspector protocol: ${label} must be an integer`) + return value as number +} + +const CONSOLE_TYPES = new Set([ + 'log', 'debug', 'info', 'error', 'warning', 'dir', 'dirxml', 'table', 'trace', 'clear', + 'startGroup', 'startGroupCollapsed', 'endGroup', 'assert', 'profile', 'profileEnd', 'count', 'timeEnd', +]) diff --git a/packages/experimental/inspector/src/shared/bridge/messages/runtime/frames.ts b/packages/experimental/inspector/src/shared/bridge/messages/runtime/frames.ts new file mode 100644 index 0000000000..a6ed8f620b --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/messages/runtime/frames.ts @@ -0,0 +1,147 @@ +/** Versioned envelopes for Worker-to-Client Runtime operations. */ + +import type { + ClientRuntimeRequestId, + ClientRuntimeSessionId, + InspectorSourceGeneration, + InspectorSourceId, +} from '../../ids.ts' +import { isPlainObject } from '../../../json.ts' +import { exactKeys, exactObject, wireId } from '../../../validation.ts' +import { INSPECTOR_PROTOCOL_VERSION } from '../../version.ts' +import { parseClientRuntimeCommand } from './command-codec.ts' +import { parseClientRuntimeResult } from './value-codec.ts' +import type { ClientRuntimeCommand, ClientRuntimeError, ClientRuntimeResult } from './commands.ts' + +/** Source capability that permits synthetic Runtime execution contexts. */ +export interface ClientRuntimeCapability { + readonly type: 'client-runtime' + readonly origin: string +} + +/** Worker request for one operation in a specific source generation and DevTools session. */ +export interface ClientRuntimeRequestFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'client-runtime/request' + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration + readonly sessionId: ClientRuntimeSessionId + readonly requestId: ClientRuntimeRequestId + readonly command: ClientRuntimeCommand +} + +/** Client response to one typed Runtime request. */ +export interface ClientRuntimeResponseFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'client-runtime/response' + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration + readonly sessionId: ClientRuntimeSessionId + readonly requestId: ClientRuntimeRequestId + readonly outcome: + | { readonly ok: true; readonly result: ClientRuntimeResult } + | { readonly ok: false; readonly error: ClientRuntimeError } +} + +/** One-way cleanup when a DevTools connection or its Runtime domain closes. */ +export interface ClientRuntimeSessionClosedFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'client-runtime/session-closed' + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration + readonly sessionId: ClientRuntimeSessionId +} + +/** + * Parse and rebuild a Client Runtime capability. + * @param value - Untrusted capability declaration. + * @returns The validated capability. + */ +export function parseClientRuntimeCapability(value: unknown): ClientRuntimeCapability { + const record = exactObject(value, ['type', 'origin'], 'Client Runtime capability') + if (record.type !== 'client-runtime' || typeof record.origin !== 'string' || record.origin.length > 2_048) { + throw new Error('inspector protocol: invalid Client Runtime capability') + } + return { type: 'client-runtime', origin: record.origin } +} + +/** + * Parse and rebuild one Worker-to-Client Runtime request. + * @param value - Untrusted request frame. + * @returns The validated request frame. + */ +export function parseClientRuntimeRequestFrame(value: Record): ClientRuntimeRequestFrame { + exactKeys(value, ['v', 't', 'sourceId', 'generation', 'sessionId', 'requestId', 'command'], 'Client Runtime request') + if (value.v !== INSPECTOR_PROTOCOL_VERSION || value.t !== 'client-runtime/request') { + throw new Error('inspector protocol: invalid Client Runtime request envelope') + } + return { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'client-runtime/request', + sourceId: wireId<'InspectorSourceId'>(value.sourceId, 'sourceId'), + generation: wireId<'InspectorSourceGeneration'>(value.generation, 'generation'), + sessionId: wireId<'ClientRuntimeSessionId'>(value.sessionId, 'sessionId'), + requestId: wireId<'ClientRuntimeRequestId'>(value.requestId, 'requestId'), + command: parseClientRuntimeCommand(value.command), + } +} + +/** + * Parse and rebuild one Client-to-Worker Runtime response. + * @param value - Untrusted response frame. + * @returns The validated response frame. + */ +export function parseClientRuntimeResponseFrame(value: Record): ClientRuntimeResponseFrame { + exactKeys(value, ['v', 't', 'sourceId', 'generation', 'sessionId', 'requestId', 'outcome'], 'Client Runtime response') + if (value.v !== INSPECTOR_PROTOCOL_VERSION || value.t !== 'client-runtime/response') { + throw new Error('inspector protocol: invalid Client Runtime response envelope') + } + return { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'client-runtime/response', + sourceId: wireId<'InspectorSourceId'>(value.sourceId, 'sourceId'), + generation: wireId<'InspectorSourceGeneration'>(value.generation, 'generation'), + sessionId: wireId<'ClientRuntimeSessionId'>(value.sessionId, 'sessionId'), + requestId: wireId<'ClientRuntimeRequestId'>(value.requestId, 'requestId'), + outcome: parseOutcome(value.outcome), + } +} + +/** + * Parse and rebuild one Runtime-session cleanup notification. + * @param value - Untrusted cleanup frame. + * @returns The validated cleanup frame. + */ +export function parseClientRuntimeSessionClosedFrame(value: Record): ClientRuntimeSessionClosedFrame { + exactKeys(value, ['v', 't', 'sourceId', 'generation', 'sessionId'], 'Client Runtime session close') + if (value.v !== INSPECTOR_PROTOCOL_VERSION || value.t !== 'client-runtime/session-closed') { + throw new Error('inspector protocol: invalid Client Runtime session close envelope') + } + return { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'client-runtime/session-closed', + sourceId: wireId<'InspectorSourceId'>(value.sourceId, 'sourceId'), + generation: wireId<'InspectorSourceGeneration'>(value.generation, 'generation'), + sessionId: wireId<'ClientRuntimeSessionId'>(value.sessionId, 'sessionId'), + } +} + +function parseOutcome(value: unknown): ClientRuntimeResponseFrame['outcome'] { + if (!isPlainObject(value) || typeof value.ok !== 'boolean') { + throw new Error('inspector protocol: invalid Client Runtime outcome') + } + if (value.ok) { + exactKeys(value, ['ok', 'result'], 'successful Client Runtime outcome') + return { ok: true, result: parseClientRuntimeResult(value.result) } + } + exactKeys(value, ['ok', 'error'], 'failed Client Runtime outcome') + const error = exactObject(value.error, ['code', 'message'], 'Client Runtime error') + if (!ERROR_CODES.has(error.code as ClientRuntimeError['code']) || typeof error.message !== 'string') { + throw new Error('inspector protocol: invalid Client Runtime error') + } + return { ok: false, error: { code: error.code as ClientRuntimeError['code'], message: error.message } } +} + +const ERROR_CODES = new Set([ + 'invalid-request', 'object-not-found', 'unsupported', 'timeout', 'result-too-large', 'internal-error', +]) diff --git a/packages/experimental/inspector/src/shared/bridge/messages/runtime/index.ts b/packages/experimental/inspector/src/shared/bridge/messages/runtime/index.ts new file mode 100644 index 0000000000..b0b569ded4 --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/messages/runtime/index.ts @@ -0,0 +1,5 @@ +/** Public types and boundary decoders for the Client Runtime wire protocol. */ + +export * from './commands.ts' +export * from './console-frames.ts' +export * from './frames.ts' diff --git a/packages/experimental/inspector/src/shared/bridge/messages/runtime/value-codec.ts b/packages/experimental/inspector/src/shared/bridge/messages/runtime/value-codec.ts new file mode 100644 index 0000000000..da9dcc0bf9 --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/messages/runtime/value-codec.ts @@ -0,0 +1,334 @@ +/** Exact wire decoder for Client Runtime results and RemoteObject data. */ + +import { isJsonValue, isPlainObject } from '../../../json.ts' +import { exactKeys, exactObject, optionalBoolean, optionalString, wireId } from '../../../validation.ts' +import { parseInspectorObjectReference } from '../../../cordis/object-reference.ts' +import type { + RuntimeCallFrame, + RuntimeObjectPreview, + RuntimePropertyPreview, + RuntimeRemoteObjectDescriptor, + RuntimeRemoteObjectSubtype, + RuntimeRemoteObjectType, + RuntimeStackTrace, +} from '../../../cdp/index.ts' +import type { + ClientRuntimeCompletion, + ClientRuntimeExceptionDetails, + ClientRuntimeInternalPropertyDescriptor, + ClientRuntimePropertyDescriptor, + ClientRuntimeRemoteObject, + ClientRuntimeResult, +} from './commands.ts' + +/** + * Parse and rebuild one successful Client Runtime result. + * @param value - Untrusted result value. + * @returns The validated result union member. + */ +export function parseClientRuntimeResult(value: unknown): ClientRuntimeResult { + if (!isPlainObject(value) || typeof value.op !== 'string') { + throw new Error('inspector protocol: Client Runtime result must have an op') + } + switch (value.op) { + case 'evaluate': + case 'call-function': + case 'await-promise': + exactKeys(value, ['op', 'completion'], `${value.op} result`) + return { op: value.op, completion: parseCompletion(value.completion) } + case 'get-properties': { + exactKeys(value, ['op', 'properties', 'internalProperties', 'exceptionDetails'], 'get-properties result') + if (!Array.isArray(value.properties)) throw new Error('inspector protocol: properties must be an array') + const internal = value.internalProperties + if (internal !== undefined && !Array.isArray(internal)) { + throw new Error('inspector protocol: internalProperties must be an array') + } + return { + op: 'get-properties', + properties: value.properties.map(parsePropertyDescriptor), + ...(internal === undefined ? {} : { internalProperties: internal.map(parseInternalPropertyDescriptor) }), + ...(value.exceptionDetails === undefined + ? {} + : { exceptionDetails: parseClientRuntimeExceptionDetails(value.exceptionDetails) }), + } + } + case 'release-object': + case 'release-object-group': + exactKeys(value, ['op'], `${value.op} result`) + return { op: value.op } + case 'global-lexical-scope-names': + exactKeys(value, ['op', 'names'], 'global-lexical-scope-names result') + if (!Array.isArray(value.names) || !value.names.every(name => typeof name === 'string')) { + throw new Error('inspector protocol: lexical scope names must be strings') + } + return { op: 'global-lexical-scope-names', names: value.names } + default: + throw new Error(`inspector protocol: unknown Client Runtime result ${JSON.stringify(value.op)}`) + } +} + +function parseCompletion(value: unknown): ClientRuntimeCompletion { + const record = exactObject(value, ['result', 'exceptionDetails'], 'Client Runtime completion') + return { + result: parseClientRuntimeRemoteObject(record.result), + ...(record.exceptionDetails === undefined + ? {} + : { exceptionDetails: parseClientRuntimeExceptionDetails(record.exceptionDetails) }), + } +} + +/** + * Decode one Client Runtime object carrying an optional session-local handle. + * @param value - Untrusted wire value. + * @returns The validated realm-neutral object value. + */ +export function parseClientRuntimeRemoteObject(value: unknown): ClientRuntimeRemoteObject { + const record = exactObject(value, ['descriptor', 'object', 'semanticReference'], 'Client Runtime object') + const descriptor = parseRemoteObjectDescriptor(record.descriptor) + const object = record.object === undefined + ? undefined + : exactObject(record.object, ['handle'], 'Client Runtime object reference') + const remote: ClientRuntimeRemoteObject = { + descriptor, + ...(object === undefined + ? {} + : { object: { handle: wireId<'ClientRemoteObjectHandle'>(object.handle, 'handle') } }), + ...(record.semanticReference === undefined + ? {} + : { semanticReference: parseInspectorObjectReference(record.semanticReference) }), + } + validateRemoteObject(remote) + return remote +} + +function parseRemoteObjectDescriptor(value: unknown): RuntimeRemoteObjectDescriptor { + const record = exactObject(value, [ + 'type', 'subtype', 'className', 'value', 'unserializableValue', 'description', 'preview', + ], 'Runtime object descriptor') + if (!REMOTE_TYPES.has(record.type as RuntimeRemoteObjectType)) { + throw new Error('inspector protocol: invalid Client RemoteObject type') + } + if (record.subtype !== undefined && !REMOTE_SUBTYPES.has(record.subtype as RuntimeRemoteObjectSubtype)) { + throw new Error('inspector protocol: invalid Client RemoteObject subtype') + } + if (record.value !== undefined && !isJsonValue(record.value)) { + throw new Error('inspector protocol: Client RemoteObject value must be JSON') + } + return { + type: record.type as RuntimeRemoteObjectType, + ...(record.subtype === undefined ? {} : { subtype: record.subtype as RuntimeRemoteObjectSubtype }), + ...optionalString(record, 'className'), + ...(record.value === undefined ? {} : { value: record.value }), + ...optionalString(record, 'unserializableValue'), + ...optionalString(record, 'description'), + ...(record.preview === undefined ? {} : { preview: parseObjectPreview(record.preview) }), + } +} + +function parseObjectPreview(value: unknown): RuntimeObjectPreview { + const record = exactObject(value, ['type', 'subtype', 'description', 'overflow', 'properties'], 'object preview') + if (!REMOTE_TYPES.has(record.type as RuntimeRemoteObjectType) + || (record.subtype !== undefined && !REMOTE_SUBTYPES.has(record.subtype as RuntimeRemoteObjectSubtype)) + || typeof record.overflow !== 'boolean' + || !Array.isArray(record.properties)) { + throw new Error('inspector protocol: invalid object preview') + } + return { + type: record.type as RuntimeRemoteObjectType, + ...(record.subtype === undefined ? {} : { subtype: record.subtype as RuntimeRemoteObjectSubtype }), + ...optionalString(record, 'description'), + overflow: record.overflow, + properties: record.properties.map(parsePropertyPreview), + } +} + +function parsePropertyPreview(value: unknown): RuntimePropertyPreview { + const record = exactObject(value, ['name', 'type', 'value', 'valuePreview', 'subtype'], 'property preview') + if (typeof record.name !== 'string' + || (record.type !== 'accessor' && !REMOTE_TYPES.has(record.type as RuntimeRemoteObjectType)) + || (record.subtype !== undefined && !REMOTE_SUBTYPES.has(record.subtype as RuntimeRemoteObjectSubtype))) { + throw new Error('inspector protocol: invalid property preview') + } + return { + name: record.name, + type: record.type as RuntimePropertyPreview['type'], + ...optionalString(record, 'value'), + ...(record.valuePreview === undefined ? {} : { valuePreview: parseObjectPreview(record.valuePreview) }), + ...(record.subtype === undefined ? {} : { subtype: record.subtype as RuntimeRemoteObjectSubtype }), + } +} + +function parsePropertyDescriptor(value: unknown): ClientRuntimePropertyDescriptor { + const record = exactObject(value, [ + 'name', 'value', 'writable', 'get', 'set', 'configurable', 'enumerable', 'wasThrown', 'isOwn', 'symbol', + ], 'property descriptor') + if (typeof record.name !== 'string' || typeof record.configurable !== 'boolean' || typeof record.enumerable !== 'boolean') { + throw new Error('inspector protocol: invalid property descriptor') + } + const dataDescriptor = record.value !== undefined || record.writable !== undefined + const accessorDescriptor = record.get !== undefined || record.set !== undefined + if (dataDescriptor && accessorDescriptor) { + throw new Error('inspector protocol: property descriptor mixes data and accessor fields') + } + return { + name: record.name, + ...(record.value === undefined ? {} : { value: parseClientRuntimeRemoteObject(record.value) }), + ...optionalBoolean(record, 'writable'), + ...(record.get === undefined ? {} : { get: parseClientRuntimeRemoteObject(record.get) }), + ...(record.set === undefined ? {} : { set: parseClientRuntimeRemoteObject(record.set) }), + configurable: record.configurable, + enumerable: record.enumerable, + ...optionalBoolean(record, 'wasThrown'), + ...optionalBoolean(record, 'isOwn'), + ...(record.symbol === undefined ? {} : { symbol: parseClientRuntimeRemoteObject(record.symbol) }), + } +} + +function parseInternalPropertyDescriptor(value: unknown): ClientRuntimeInternalPropertyDescriptor { + const record = exactObject(value, ['name', 'value'], 'internal property descriptor') + if (typeof record.name !== 'string') throw new Error('inspector protocol: invalid internal property descriptor') + return { + name: record.name, + ...(record.value === undefined ? {} : { value: parseClientRuntimeRemoteObject(record.value) }), + } +} + +/** + * Decode Client exception details used by command results and events. + * @param value - Untrusted wire value. + * @returns Validated exception details. + */ +export function parseClientRuntimeExceptionDetails(value: unknown): ClientRuntimeExceptionDetails { + const record = exactObject(value, [ + 'text', 'lineNumber', 'columnNumber', 'url', 'stackTrace', 'exception', + ], 'exception details') + if (typeof record.text !== 'string' + || !Number.isSafeInteger(record.lineNumber) + || (record.lineNumber as number) < 0 + || !Number.isSafeInteger(record.columnNumber) + || (record.columnNumber as number) < 0) { + throw new Error('inspector protocol: invalid exception details') + } + return { + text: record.text, + lineNumber: record.lineNumber as number, + columnNumber: record.columnNumber as number, + ...optionalString(record, 'url'), + ...(record.stackTrace === undefined ? {} : { stackTrace: parseClientRuntimeStackTrace(record.stackTrace) }), + ...(record.exception === undefined ? {} : { exception: parseClientRuntimeRemoteObject(record.exception) }), + } +} + +/** + * Decode a stack trace carried by a Client Runtime or Console frame. + * @param value - Untrusted stack-trace value. + * @returns The validated realm-neutral stack trace. + */ +export function parseClientRuntimeStackTrace(value: unknown): RuntimeStackTrace { + const record = exactObject(value, ['description', 'callFrames', 'parent'], 'stack trace') + if (!Array.isArray(record.callFrames)) throw new Error('inspector protocol: stack callFrames must be an array') + return { + ...optionalString(record, 'description'), + callFrames: record.callFrames.map(parseCallFrame), + ...(record.parent === undefined ? {} : { parent: parseClientRuntimeStackTrace(record.parent) }), + } +} + +function parseCallFrame(value: unknown): RuntimeCallFrame { + const record = exactObject(value, ['functionName', 'scriptKey', 'url', 'lineNumber', 'columnNumber'], 'stack call frame') + if (typeof record.functionName !== 'string' + || typeof record.url !== 'string' + || !Number.isSafeInteger(record.lineNumber) + || !Number.isSafeInteger(record.columnNumber)) { + throw new Error('inspector protocol: invalid stack call frame') + } + return { + functionName: record.functionName, + ...(record.scriptKey === undefined ? {} : { scriptKey: wireId<'RuntimeScriptKey'>(record.scriptKey, 'scriptKey') }), + url: record.url, + lineNumber: record.lineNumber as number, + columnNumber: record.columnNumber as number, + } +} + +const REMOTE_TYPES = new Set([ + 'object', 'function', 'undefined', 'string', 'number', 'boolean', 'symbol', 'bigint', +]) + +const REMOTE_SUBTYPES = new Set([ + 'array', 'null', 'node', 'regexp', 'date', 'map', 'set', 'weakmap', 'weakset', 'iterator', 'generator', + 'error', 'proxy', 'promise', 'typedarray', 'arraybuffer', 'dataview', 'webassemblymemory', 'wasmvalue', +]) + +function validateRemoteObject(value: ClientRuntimeRemoteObject): void { + if (value.semanticReference !== undefined && value.object === undefined) { + throw new Error('inspector protocol: semanticReference requires a retained Client object') + } + const descriptor = value.descriptor + if (descriptor.subtype !== undefined && descriptor.type !== 'object') { + throw new Error('inspector protocol: only object RemoteObjects may have a subtype') + } + if (descriptor.preview !== undefined && descriptor.type !== 'object') { + throw new Error('inspector protocol: only object RemoteObjects may have a preview') + } + const hasValue = descriptor.value !== undefined + const hasUnserializableValue = descriptor.unserializableValue !== undefined + const hasObject = value.object !== undefined + switch (descriptor.type) { + case 'undefined': + requireRepresentations(descriptor.type, hasValue, hasUnserializableValue, hasObject, false, false, false) + return + case 'string': + requireRepresentations(descriptor.type, typeof descriptor.value === 'string', hasUnserializableValue, hasObject, true, false, false) + return + case 'boolean': + requireRepresentations(descriptor.type, typeof descriptor.value === 'boolean', hasUnserializableValue, hasObject, true, false, false) + return + case 'number': { + const finite = typeof descriptor.value === 'number' + && Number.isFinite(descriptor.value) + && !Object.is(descriptor.value, -0) + const special = descriptor.unserializableValue === 'NaN' + || descriptor.unserializableValue === 'Infinity' + || descriptor.unserializableValue === '-Infinity' + || descriptor.unserializableValue === '-0' + if (hasObject || finite === special) throw new Error('inspector protocol: invalid number RemoteObject representation') + return + } + case 'bigint': + if (hasValue || hasObject || !/^-?(?:0|[1-9]\d*)n$/u.test(descriptor.unserializableValue ?? '')) { + throw new Error('inspector protocol: invalid bigint RemoteObject representation') + } + return + case 'symbol': + case 'function': + requireRepresentations(descriptor.type, hasValue, hasUnserializableValue, hasObject, false, false, true) + return + case 'object': + if (descriptor.subtype === 'null') { + if (descriptor.value !== null || hasObject || hasUnserializableValue) { + throw new Error('inspector protocol: invalid null RemoteObject representation') + } + return + } + if (hasUnserializableValue || hasValue === hasObject) { + throw new Error('inspector protocol: object RemoteObject needs exactly one value or backend object') + } + } +} + +function requireRepresentations( + type: RuntimeRemoteObjectType, + hasValue: boolean, + hasUnserializableValue: boolean, + hasObject: boolean, + expectedValue: boolean, + expectedUnserializableValue: boolean, + expectedObject: boolean, +): void { + if (hasValue !== expectedValue + || hasUnserializableValue !== expectedUnserializableValue + || hasObject !== expectedObject) { + throw new Error(`inspector protocol: invalid ${type} RemoteObject representation`) + } +} diff --git a/packages/experimental/inspector/src/shared/bridge/messages/sources/codec.ts b/packages/experimental/inspector/src/shared/bridge/messages/sources/codec.ts new file mode 100644 index 0000000000..a774830cff --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/messages/sources/codec.ts @@ -0,0 +1,127 @@ +/** Exact decoders for Client source catalog operations and values. */ + +import { isPlainObject } from '../../../json.ts' +import type { RuntimeScript } from '../../../cdp/index.ts' +import { exactKeys, exactObject, optionalBoolean, optionalString, wireId } from '../../../validation.ts' +import type { + ClientScriptDescriptor, + ClientSourceCommand, + ClientSourceContentKind, + ClientSourceResult, +} from './commands.ts' + +/** + * Parse one Worker-to-Client source command. + * @param value - Untrusted decoded command. + * @returns The validated command. + */ +export function parseClientSourceCommand(value: unknown): ClientSourceCommand { + if (!isPlainObject(value) || typeof value.op !== 'string') { + throw new Error('inspector protocol: Client source command must have an op') + } + if (value.op === 'list-scripts') { + exactKeys(value, ['op'], 'Client source list command') + return { op: 'list-scripts' } + } + if (value.op !== 'get-content-chunk') { + throw new Error(`inspector protocol: unknown Client source command ${JSON.stringify(value.op)}`) + } + exactKeys(value, ['op', 'scriptKey', 'content', 'offset', 'maxBytes'], 'Client source chunk command') + return { + op: 'get-content-chunk', + scriptKey: wireId<'RuntimeScriptKey'>(value.scriptKey, 'scriptKey'), + content: contentKind(value.content), + offset: natural(value.offset, 'offset', true), + maxBytes: natural(value.maxBytes, 'maxBytes', false), + } +} + +/** + * Parse one successful Client source result. + * @param value - Untrusted decoded result. + * @returns The validated result. + */ +export function parseClientSourceResult(value: unknown): ClientSourceResult { + if (!isPlainObject(value) || typeof value.op !== 'string') { + throw new Error('inspector protocol: Client source result must have an op') + } + if (value.op === 'list-scripts') { + exactKeys(value, ['op', 'scripts'], 'Client source list result') + if (!Array.isArray(value.scripts)) throw new Error('inspector protocol: Client source scripts must be an array') + return { op: 'list-scripts', scripts: value.scripts.map(parseScript) } + } + if (value.op !== 'get-content-chunk') { + throw new Error(`inspector protocol: unknown Client source result ${JSON.stringify(value.op)}`) + } + if (value.available === false) { + exactKeys(value, ['op', 'scriptKey', 'content', 'available'], 'unavailable Client source chunk') + return { + op: 'get-content-chunk', + scriptKey: wireId<'RuntimeScriptKey'>(value.scriptKey, 'scriptKey'), + content: contentKind(value.content), + available: false, + } + } + exactKeys( + value, + ['op', 'scriptKey', 'content', 'available', 'offset', 'nextOffset', 'data', 'eof'], + 'Client source chunk result', + ) + if (value.available !== true || typeof value.data !== 'string' || typeof value.eof !== 'boolean') { + throw new Error('inspector protocol: invalid Client source chunk result') + } + const offset = natural(value.offset, 'offset', true) + const nextOffset = natural(value.nextOffset, 'nextOffset', true) + if (nextOffset < offset || !BASE64.test(value.data)) { + throw new Error('inspector protocol: invalid Client source chunk data') + } + return { + op: 'get-content-chunk', + scriptKey: wireId<'RuntimeScriptKey'>(value.scriptKey, 'scriptKey'), + content: contentKind(value.content), + available: true, + offset, + nextOffset, + data: value.data, + eof: value.eof, + } +} + +function parseScript(value: unknown): ClientScriptDescriptor { + const record = exactObject(value, [ + 'scriptKey', 'url', 'hash', 'buildId', 'sourceMapUrl', 'startLine', 'startColumn', 'endLine', 'endColumn', + 'isModule', 'length', + ], 'Client script descriptor') + if (typeof record.url !== 'string' || record.url.length > 8_192 || typeof record.hash !== 'string') { + throw new Error('inspector protocol: invalid Client script identity') + } + return { + scriptKey: wireId<'RuntimeScriptKey'>(record.scriptKey, 'scriptKey'), + url: record.url, + hash: record.hash, + ...optionalString(record, 'buildId'), + ...optionalString(record, 'sourceMapUrl'), + startLine: natural(record.startLine, 'startLine', true), + startColumn: natural(record.startColumn, 'startColumn', true), + endLine: natural(record.endLine, 'endLine', true), + endColumn: natural(record.endColumn, 'endColumn', true), + ...optionalBoolean(record, 'isModule'), + ...(record.length === undefined ? {} : { length: natural(record.length, 'length', true) }), + } satisfies Omit +} + +function contentKind(value: unknown): ClientSourceContentKind { + if (value !== 'source' && value !== 'source-map') { + throw new Error('inspector protocol: invalid Client source content kind') + } + return value +} + +function natural(value: unknown, label: string, zero: boolean): number { + if (!Number.isSafeInteger(value) || (value as number) < (zero ? 0 : 1)) { + throw new Error(`inspector protocol: ${label} must be ${zero ? 'a non-negative' : 'a positive'} integer`) + } + return value as number +} + +const BASE64 = /^(?:[A-Za-z\d+/]{4})*(?:[A-Za-z\d+/]{2}==|[A-Za-z\d+/]{3}=)?$/u diff --git a/packages/experimental/inspector/src/shared/bridge/messages/sources/commands.ts b/packages/experimental/inspector/src/shared/bridge/messages/sources/commands.ts new file mode 100644 index 0000000000..bb39bfc0d1 --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/messages/sources/commands.ts @@ -0,0 +1,47 @@ +/** Operations and values exchanged with a Client realm's read-only source catalog. */ + +import type { RuntimeScriptKey } from '../../../cdp/ids.ts' +import type { RuntimeScript } from '../../../cdp/index.ts' + +/** Script metadata that excludes the Worker-owned execution-context id. */ +export type ClientScriptDescriptor = Omit + +/** Content stored for one Client script. */ +export type ClientSourceContentKind = 'source' | 'source-map' + +/** Read-only operation accepted by the Client source catalog. */ +export type ClientSourceCommand = + | { readonly op: 'list-scripts' } + | { + readonly op: 'get-content-chunk' + readonly scriptKey: RuntimeScriptKey + readonly content: ClientSourceContentKind + readonly offset: number + readonly maxBytes: number + } + +/** Successful result of one Client source operation. */ +export type ClientSourceResult = + | { readonly op: 'list-scripts'; readonly scripts: readonly ClientScriptDescriptor[] } + | { + readonly op: 'get-content-chunk' + readonly scriptKey: RuntimeScriptKey + readonly content: ClientSourceContentKind + readonly available: false + } + | { + readonly op: 'get-content-chunk' + readonly scriptKey: RuntimeScriptKey + readonly content: ClientSourceContentKind + readonly available: true + readonly offset: number + readonly nextOffset: number + readonly data: string + readonly eof: boolean + } + +/** Deliberate failure returned by the Client source catalog. */ +export interface ClientSourceError { + readonly code: 'invalid-request' | 'script-not-found' | 'load-failed' | 'result-too-large' | 'internal-error' + readonly message: string +} diff --git a/packages/experimental/inspector/src/shared/bridge/messages/sources/frames.ts b/packages/experimental/inspector/src/shared/bridge/messages/sources/frames.ts new file mode 100644 index 0000000000..9feb4bcec6 --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/messages/sources/frames.ts @@ -0,0 +1,143 @@ +/** Versioned envelopes for Client source catalog operations. */ + +import type { + ClientSourceRequestId, + ClientSourceSessionId, + InspectorSourceGeneration, + InspectorSourceId, +} from '../../ids.ts' +import { isPlainObject } from '../../../json.ts' +import { exactKeys, exactObject, wireId } from '../../../validation.ts' +import { INSPECTOR_PROTOCOL_VERSION } from '../../version.ts' +import { parseClientSourceCommand, parseClientSourceResult } from './codec.ts' +import type { ClientSourceCommand, ClientSourceError, ClientSourceResult } from './commands.ts' + +/** Source capability that permits read-only Client script discovery. */ +export interface ClientSourcesCapability { + readonly type: 'client-sources' +} + +/** Worker request for one operation in a Client source catalog. */ +export interface ClientSourceRequestFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'client-sources/request' + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration + readonly sessionId: ClientSourceSessionId + readonly requestId: ClientSourceRequestId + readonly command: ClientSourceCommand +} + +/** Client response to one source catalog operation. */ +export interface ClientSourceResponseFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'client-sources/response' + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration + readonly sessionId: ClientSourceSessionId + readonly requestId: ClientSourceRequestId + readonly outcome: + | { readonly ok: true; readonly result: ClientSourceResult } + | { readonly ok: false; readonly error: ClientSourceError } +} + +/** One-way cleanup for in-flight operations owned by a closed DevTools session. */ +export interface ClientSourceSessionClosedFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'client-sources/session-closed' + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration + readonly sessionId: ClientSourceSessionId +} + +/** + * Parse the marker capability for a Client source catalog. + * @param value - Untrusted capability declaration. + * @returns The validated marker capability. + */ +export function parseClientSourcesCapability(value: unknown): ClientSourcesCapability { + const record = exactObject(value, ['type'], 'Client Sources capability') + if (record.type !== 'client-sources') throw new Error('inspector protocol: invalid Client Sources capability') + return { type: 'client-sources' } +} + +/** + * Parse one Worker-to-Client source request. + * @param value - Untrusted decoded request. + * @returns The validated request frame. + */ +export function parseClientSourceRequestFrame(value: Record): ClientSourceRequestFrame { + exactKeys(value, ['v', 't', 'sourceId', 'generation', 'sessionId', 'requestId', 'command'], 'Client source request') + if (value.v !== INSPECTOR_PROTOCOL_VERSION || value.t !== 'client-sources/request') { + throw new Error('inspector protocol: invalid Client source request envelope') + } + return { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'client-sources/request', + sourceId: wireId<'InspectorSourceId'>(value.sourceId, 'sourceId'), + generation: wireId<'InspectorSourceGeneration'>(value.generation, 'generation'), + sessionId: wireId<'ClientSourceSessionId'>(value.sessionId, 'sessionId'), + requestId: wireId<'ClientSourceRequestId'>(value.requestId, 'requestId'), + command: parseClientSourceCommand(value.command), + } +} + +/** + * Parse one Client-to-Worker source response. + * @param value - Untrusted decoded response. + * @returns The validated response frame. + */ +export function parseClientSourceResponseFrame(value: Record): ClientSourceResponseFrame { + exactKeys(value, ['v', 't', 'sourceId', 'generation', 'sessionId', 'requestId', 'outcome'], 'Client source response') + if (value.v !== INSPECTOR_PROTOCOL_VERSION || value.t !== 'client-sources/response') { + throw new Error('inspector protocol: invalid Client source response envelope') + } + return { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'client-sources/response', + sourceId: wireId<'InspectorSourceId'>(value.sourceId, 'sourceId'), + generation: wireId<'InspectorSourceGeneration'>(value.generation, 'generation'), + sessionId: wireId<'ClientSourceSessionId'>(value.sessionId, 'sessionId'), + requestId: wireId<'ClientSourceRequestId'>(value.requestId, 'requestId'), + outcome: parseOutcome(value.outcome), + } +} + +/** + * Parse one Client source-session cleanup notification. + * @param value - Untrusted decoded notification. + * @returns The validated cleanup frame. + */ +export function parseClientSourceSessionClosedFrame(value: Record): ClientSourceSessionClosedFrame { + exactKeys(value, ['v', 't', 'sourceId', 'generation', 'sessionId'], 'Client source session close') + if (value.v !== INSPECTOR_PROTOCOL_VERSION || value.t !== 'client-sources/session-closed') { + throw new Error('inspector protocol: invalid Client source session close envelope') + } + return { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'client-sources/session-closed', + sourceId: wireId<'InspectorSourceId'>(value.sourceId, 'sourceId'), + generation: wireId<'InspectorSourceGeneration'>(value.generation, 'generation'), + sessionId: wireId<'ClientSourceSessionId'>(value.sessionId, 'sessionId'), + } +} + +function parseOutcome(value: unknown): ClientSourceResponseFrame['outcome'] { + if (!isPlainObject(value) || typeof value.ok !== 'boolean') { + throw new Error('inspector protocol: invalid Client source outcome') + } + if (value.ok) { + exactKeys(value, ['ok', 'result'], 'successful Client source outcome') + return { ok: true, result: parseClientSourceResult(value.result) } + } + exactKeys(value, ['ok', 'error'], 'failed Client source outcome') + const error = exactObject(value.error, ['code', 'message'], 'Client source error') + if (!ERROR_CODES.has(error.code as ClientSourceError['code']) || typeof error.message !== 'string') { + throw new Error('inspector protocol: invalid Client source error') + } + return { ok: false, error: { code: error.code as ClientSourceError['code'], message: error.message } } +} + +const ERROR_CODES = new Set([ + 'invalid-request', 'script-not-found', 'load-failed', 'result-too-large', 'internal-error', +]) diff --git a/packages/experimental/inspector/src/shared/bridge/messages/sources/index.ts b/packages/experimental/inspector/src/shared/bridge/messages/sources/index.ts new file mode 100644 index 0000000000..29831039eb --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/messages/sources/index.ts @@ -0,0 +1,5 @@ +/** Public types and decoders for the Client source catalog protocol. */ + +export * from './codec.ts' +export * from './commands.ts' +export * from './frames.ts' diff --git a/packages/experimental/inspector/src/shared/bridge/publisher.ts b/packages/experimental/inspector/src/shared/bridge/publisher.ts new file mode 100644 index 0000000000..a383f587c9 --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/publisher.ts @@ -0,0 +1,24 @@ +/** Source-side interfaces shared by MessagePort and WebSocket bridge implementations. */ + +import type { InspectorJsonValue } from '../json.ts' +import type { InspectorQueryRequester } from './messages/query/commands.ts' + +/** Transport-independent observation publisher. */ +export interface InspectorPublisher { + /** Publish one validated observation. */ + publish(topic: string, payload: InspectorJsonValue, monotonicMs?: number): void +} + +/** Publisher that also retains the latest value of stateful observation topics. */ +export interface InspectorStatePublisher extends InspectorPublisher { + /** + * Replace one topic's retained state and publish the replacement. + * @param topic - Domain-owned state topic. + * @param payload - Latest JSON state, replayed after source resynchronization. + * @param monotonicMs - Source-clock timestamp; defaults to `performance.now()`. + */ + setState(topic: string, payload: InspectorJsonValue, monotonicMs?: number): void +} + +/** Shared capabilities exposed above a Host MessagePort or Client WebSocket carrier. */ +export interface InspectorConnection extends InspectorStatePublisher, InspectorQueryRequester {} diff --git a/packages/experimental/inspector/src/shared/bridge/rpc.ts b/packages/experimental/inspector/src/shared/bridge/rpc.ts new file mode 100644 index 0000000000..24bfd3827a --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/rpc.ts @@ -0,0 +1,182 @@ +/** Shared Host/Client owner of correlated non-CDP query requests. */ + +import { inspectorId, type InspectorSourceGeneration, type InspectorSourceId } from './ids.ts' +import { jsonByteLength, type InspectorJsonValue } from '../json.ts' +import { INSPECTOR_PROTOCOL_VERSION } from './version.ts' +import type { + InspectorQuery, + InspectorQueryError, + InspectorQueryRequester, + InspectorQueryResult, + InspectorQueryResultFor, +} from './messages/query/commands.ts' +import { isInspectorQueryResponseEnvelope, parseInspectorQueryResponseFrame } from './messages/query/codec.ts' +import type { InspectorQueryRequestFrame, InspectorQueryRequestId } from './messages/query/frames.ts' + +/** Active carrier write used by the shared query owner. */ +export interface InspectorQuerySender { + /** + * Send one validated query request frame. + * @param frame - Request belonging to the active source generation. + */ + send(frame: InspectorQueryRequestFrame): void +} + +/** Bounds applied by one Host or Client query connection. */ +export interface InspectorQueryConnectionOptions { + readonly timeoutMs: number + readonly maxFrameBytes: number +} + +interface PendingQuery { + readonly op: string + readonly resolve: (result: InspectorQueryResult) => void + readonly reject: (error: Error) => void + readonly timer: ReturnType +} + +interface QueryGeneration { + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration + readonly sender: InspectorQuerySender +} + +/** Failure deliberately returned by the Worker query handler. */ +export class InspectorQueryRemoteError extends Error { + constructor(readonly code: InspectorQueryError['code'], message: string) { + super(message) + } +} + +/** Correlates requests for one reconnecting Host or Client source. */ +export class InspectorQueryConnection implements InspectorQueryRequester { + private readonly pending = new Map() + private active: QueryGeneration | undefined + private nextRequestId = 0 + private closed = false + + constructor(private readonly options: InspectorQueryConnectionOptions) {} + + /** + * Admit the source generation acknowledged by the Worker. + * @param sourceId - Stable source identity. + * @param generation - Newly accepted transport generation. + * @param sender - Carrier writer valid for that generation. + */ + connect(sourceId: InspectorSourceId, generation: InspectorSourceGeneration, sender: InspectorQuerySender): void { + if (this.closed) throw new Error('inspector query connection is closed') + this.disconnect('Inspector source generation replaced') + this.active = { sourceId, generation, sender } + } + + /** + * Execute a query against the currently accepted source generation. + * @param query - Closed typed query command. + * @returns The result with the same operation discriminant. + */ + request(query: Query): Promise> { + const active = this.active + if (this.closed || active === undefined) { + return Promise.reject(new Error('Inspector query transport is not connected')) + } + const requestId = inspectorId<'InspectorQueryRequestId'>(`query-${String(++this.nextRequestId)}`, 'requestId') + const frame: InspectorQueryRequestFrame = { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'query/request', + sourceId: active.sourceId, + generation: active.generation, + requestId, + query, + } + if (jsonByteLength(frame as unknown as InspectorJsonValue) > this.options.maxFrameBytes) { + return Promise.reject(new Error(`Inspector query request exceeds ${String(this.options.maxFrameBytes)} bytes`)) + } + const result = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(requestId) + reject(new Error(`Inspector query ${query.op} timed out after ${String(this.options.timeoutMs)}ms`)) + }, this.options.timeoutMs) + this.pending.set(requestId, { op: query.op, resolve, reject, timer }) + try { + active.sender.send(frame) + } catch (error) { + this.rejectPending(requestId, renderError(error)) + } + }) + return result as Promise> + } + + /** + * Consume a decoded carrier value when it is a query response. + * @param value - Untrusted Worker-to-source value. + * @returns Whether the value belonged to the query protocol. + */ + receive(value: unknown): boolean { + if (!isInspectorQueryResponseEnvelope(value)) return false + let frame + try { + frame = parseInspectorQueryResponseFrame(value) + if (jsonByteLength(frame as unknown as InspectorJsonValue) > this.options.maxFrameBytes) { + throw new Error(`inspector protocol: query response exceeds ${String(this.options.maxFrameBytes)} bytes`) + } + } catch (error) { + this.disconnect(`Invalid Inspector query response: ${renderError(error).message}`) + throw error + } + const pending = this.pending.get(frame.requestId) + if (pending === undefined) return true + const active = this.active + if (active === undefined || frame.sourceId !== active.sourceId || frame.generation !== active.generation) { + this.rejectPending(frame.requestId, new Error('Inspector query response source generation does not match')) + return true + } + if (!frame.outcome.ok) { + this.rejectPending(frame.requestId, new InspectorQueryRemoteError( + frame.outcome.error.code, + frame.outcome.error.message, + )) + return true + } + if (frame.outcome.result.op !== pending.op) { + this.rejectPending(frame.requestId, new Error( + `Inspector query response op ${frame.outcome.result.op} does not match ${pending.op}`, + )) + return true + } + clearTimeout(pending.timer) + this.pending.delete(frame.requestId) + pending.resolve(frame.outcome.result) + return true + } + + /** + * Reject active requests while permitting a later source generation. + * @param reason - Failure reported to every pending caller. + */ + disconnect(reason: string): void { + this.active = undefined + for (const requestId of [...this.pending.keys()]) this.rejectPending(requestId, new Error(reason)) + } + + /** + * Permanently reject requests and prevent later reconnection. + * @param reason - Failure reported to every pending caller. + */ + close(reason = 'Inspector query connection closed'): void { + if (this.closed) return + this.closed = true + this.disconnect(reason) + } + + private rejectPending(requestId: InspectorQueryRequestId, error: Error): void { + const pending = this.pending.get(requestId) + if (pending === undefined) return + clearTimeout(pending.timer) + this.pending.delete(requestId) + pending.reject(error) + } +} + +function renderError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)) +} diff --git a/packages/experimental/inspector/src/shared/bridge/validation.ts b/packages/experimental/inspector/src/shared/bridge/validation.ts new file mode 100644 index 0000000000..76a776bbfa --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/validation.ts @@ -0,0 +1,3 @@ +/** Bridge-facing exports for the shared untrusted-value validation primitives. */ + +export * from '../validation.ts' diff --git a/packages/experimental/inspector/src/shared/bridge/version.ts b/packages/experimental/inspector/src/shared/bridge/version.ts new file mode 100644 index 0000000000..7fca05a3c6 --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/version.ts @@ -0,0 +1,2 @@ +/** Current Inspector wire version. Pre-release peers reject every other version. */ +export const INSPECTOR_PROTOCOL_VERSION = 0 as const diff --git a/packages/experimental/inspector/src/shared/cdp/capabilities.ts b/packages/experimental/inspector/src/shared/cdp/capabilities.ts new file mode 100644 index 0000000000..d0eb408699 --- /dev/null +++ b/packages/experimental/inspector/src/shared/cdp/capabilities.ts @@ -0,0 +1,28 @@ +/** Explicit operation support advertised by each Inspector realm. */ + +/** Runtime operations implemented by a realm backend. */ +export type RuntimeOperation = + | 'evaluate' + | 'get-properties' + | 'call-function' + | 'await-promise' + | 'release-object' + | 'release-object-group' + | 'global-lexical-scope-names' + +/** Console operations implemented by a realm backend. */ +export type ConsoleOperation = 'events' | 'exceptions' | 'clear' + +/** Source catalog operations implemented by a realm backend. */ +export type SourceOperation = 'catalog' | 'content' | 'source-map' + +/** Active debugger operations implemented by a realm backend. */ +export type DebuggerOperation = 'breakpoint' | 'pause' | 'resume' | 'step' | 'call-frame' + +/** Complete capability declaration for one inspected realm. */ +export interface InspectorRealmCapabilities { + readonly runtime: readonly RuntimeOperation[] + readonly console: readonly ConsoleOperation[] + readonly sources: readonly SourceOperation[] + readonly debugger: readonly DebuggerOperation[] +} diff --git a/packages/experimental/inspector/src/shared/cdp/console.ts b/packages/experimental/inspector/src/shared/cdp/console.ts new file mode 100644 index 0000000000..9655ce54a7 --- /dev/null +++ b/packages/experimental/inspector/src/shared/cdp/console.ts @@ -0,0 +1,46 @@ +/** Realm-neutral Console events emitted by Runtime backends. */ + +import type { RuntimeRemoteObject } from './remote-object.ts' +import type { RuntimeExceptionDetails, RuntimeStackTrace } from './errors.ts' + +/** Console API categories exposed by CDP Runtime. */ +export type RuntimeConsoleType = + | 'log' + | 'debug' + | 'info' + | 'error' + | 'warning' + | 'dir' + | 'dirxml' + | 'table' + | 'trace' + | 'clear' + | 'startGroup' + | 'startGroupCollapsed' + | 'endGroup' + | 'assert' + | 'profile' + | 'profileEnd' + | 'count' + | 'timeEnd' + +/** One Console event associated with a single inspected realm. */ +export interface RuntimeConsoleEvent { + readonly type: RuntimeConsoleType + readonly arguments: readonly RuntimeRemoteObject[] + readonly timestamp: number + readonly contextId?: number + readonly stackTrace?: RuntimeStackTrace +} + +/** One uncaught exception observed in an inspected realm. */ +export interface RuntimeExceptionEvent { + readonly timestamp: number + readonly contextId?: number + readonly details: RuntimeExceptionDetails +} + +/** Console-domain event emitted by a realm backend. */ +export type RuntimeConsoleBackendEvent = + | { readonly type: 'console-api'; readonly event: RuntimeConsoleEvent } + | { readonly type: 'exception'; readonly event: RuntimeExceptionEvent } diff --git a/packages/experimental/inspector/src/shared/cdp/debugger.ts b/packages/experimental/inspector/src/shared/cdp/debugger.ts new file mode 100644 index 0000000000..244b1acd1b --- /dev/null +++ b/packages/experimental/inspector/src/shared/cdp/debugger.ts @@ -0,0 +1,78 @@ +/** Realm-neutral values used by active debugger backends. */ + +import type { InspectorJsonValue } from '../json.ts' +import type { RuntimeScriptKey } from './ids.ts' +import type { RuntimeStackTrace } from './errors.ts' +import type { RuntimeCompletion } from './operations.ts' +import type { RuntimeRemoteObject } from './remote-object.ts' + +/** One source location independent of a CDP ScriptId allocation policy. */ +export interface RuntimeDebuggerLocation { + readonly scriptKey: RuntimeScriptKey + readonly lineNumber: number + readonly columnNumber?: number +} + +/** One lexical scope attached to a paused call frame. */ +export interface RuntimeDebuggerScope { + readonly type: string + readonly object: RuntimeRemoteObject + readonly name?: string + readonly startLocation?: RuntimeDebuggerLocation + readonly endLocation?: RuntimeDebuggerLocation +} + +/** One paused JavaScript call frame. */ +export interface RuntimeDebuggerCallFrame { + readonly callFrameId: string + readonly functionName: string + readonly functionLocation?: RuntimeDebuggerLocation + readonly location: RuntimeDebuggerLocation + readonly url: string + readonly scopeChain: readonly RuntimeDebuggerScope[] + readonly thisObject: RuntimeRemoteObject + readonly returnValue?: RuntimeRemoteObject +} + +/** Engine-independent evaluation request for one paused call frame. */ +export interface RuntimeCallFrameEvaluationRequest { + readonly callFrameId: string + readonly expression: string + readonly objectGroup?: string + readonly includeCommandLineAPI?: boolean + readonly silent?: boolean + readonly returnByValue?: boolean + readonly generatePreview?: boolean + readonly throwOnSideEffect?: boolean + readonly timeoutMs?: number +} + +/** Optional native script-cache limit requested while enabling Debugger. */ +export interface RuntimeDebuggerEnableRequest { + readonly maxScriptsCacheSize?: number +} + +/** Optional termination requested while resuming a native debugger. */ +export interface RuntimeDebuggerResumeRequest { + readonly terminateOnResume?: boolean +} + +/** Debugger lifecycle notification emitted by a realm backend. */ +export type RuntimeDebuggerEvent = + | { + readonly type: 'paused' + readonly callFrames: readonly RuntimeDebuggerCallFrame[] + readonly reason: string + readonly data?: InspectorJsonValue + readonly hitBreakpoints?: readonly string[] + readonly asyncStackTrace?: RuntimeStackTrace + } + | { readonly type: 'resumed' } + | { + readonly type: 'breakpoint-resolved' + readonly breakpointId: string + readonly location: RuntimeDebuggerLocation + } + +/** Active debugger operation result containing a Runtime value. */ +export type RuntimeCallFrameEvaluation = RuntimeCompletion diff --git a/packages/experimental/inspector/src/shared/cdp/errors.ts b/packages/experimental/inspector/src/shared/cdp/errors.ts new file mode 100644 index 0000000000..d11f949d57 --- /dev/null +++ b/packages/experimental/inspector/src/shared/cdp/errors.ts @@ -0,0 +1,30 @@ +/** Realm-neutral JavaScript exception and stack information. */ + +import type { RuntimeScriptKey } from './ids.ts' +import type { RuntimeRemoteObject } from './remote-object.ts' + +/** One source location in a Runtime exception stack. */ +export interface RuntimeCallFrame { + readonly functionName: string + readonly scriptKey?: RuntimeScriptKey + readonly url: string + readonly lineNumber: number + readonly columnNumber: number +} + +/** JavaScript stack information independent of a Debugger script id. */ +export interface RuntimeStackTrace { + readonly description?: string + readonly callFrames: readonly RuntimeCallFrame[] + readonly parent?: RuntimeStackTrace +} + +/** JavaScript exception produced while executing one Runtime command. */ +export interface RuntimeExceptionDetails { + readonly text: string + readonly lineNumber: number + readonly columnNumber: number + readonly url?: string + readonly stackTrace?: RuntimeStackTrace + readonly exception?: RuntimeRemoteObject +} diff --git a/packages/experimental/inspector/src/shared/cdp/ids.ts b/packages/experimental/inspector/src/shared/cdp/ids.ts new file mode 100644 index 0000000000..7bfd19547f --- /dev/null +++ b/packages/experimental/inspector/src/shared/cdp/ids.ts @@ -0,0 +1,12 @@ +/** Opaque identifiers owned by normalized realm backends. */ + +import type { InspectorId } from '../identity.ts' + +/** Worker identity of one active Host or Client realm incarnation. */ +export type InspectorRealmId = InspectorId<'InspectorRealmId'> + +/** Backend-owned object handle interpreted only by its realm session. */ +export type RuntimeBackendObjectHandle = InspectorId<'RuntimeBackendObjectHandle'> + +/** Backend-independent identity of one script in a realm catalog. */ +export type RuntimeScriptKey = InspectorId<'RuntimeScriptKey'> diff --git a/packages/experimental/inspector/src/shared/cdp/index.ts b/packages/experimental/inspector/src/shared/cdp/index.ts new file mode 100644 index 0000000000..5a0ea5bcdb --- /dev/null +++ b/packages/experimental/inspector/src/shared/cdp/index.ts @@ -0,0 +1,11 @@ +/** Realm-neutral Runtime, Console, Source, and Debugger protocol types. */ + +export * from './capabilities.ts' +export * from './console.ts' +export * from './debugger.ts' +export * from './errors.ts' +export * from './ids.ts' +export * from './operations.ts' +export * from './property.ts' +export * from './remote-object.ts' +export * from './sources.ts' diff --git a/packages/experimental/inspector/src/shared/cdp/operations.ts b/packages/experimental/inspector/src/shared/cdp/operations.ts new file mode 100644 index 0000000000..56692a22dc --- /dev/null +++ b/packages/experimental/inspector/src/shared/cdp/operations.ts @@ -0,0 +1,80 @@ +/** Realm-neutral Runtime operations and results. */ + +import type { InspectorJsonObject, InspectorJsonValue } from '../json.ts' +import type { RuntimeExceptionDetails } from './errors.ts' +import type { + RuntimeInternalPropertyDescriptor, + RuntimePrivatePropertyDescriptor, + RuntimePropertyDescriptor, +} from './property.ts' +import type { RuntimeRemoteObject } from './remote-object.ts' + +/** One argument supplied to a function in an inspected realm. */ +export type RuntimeCallArgument = + | { readonly kind: 'value'; readonly value: InspectorJsonValue } + | { readonly kind: 'unserializable'; readonly value: string } + | { readonly kind: 'object'; readonly handle: Handle } + | { readonly kind: 'undefined' } + +/** Engine-independent evaluation options supported by Runtime backends. */ +export interface RuntimeEvaluateRequest { + readonly expression: string + readonly objectGroup?: string + readonly includeCommandLineAPI?: boolean + readonly silent?: boolean + readonly returnByValue?: boolean + readonly generatePreview?: boolean + readonly userGesture?: boolean + readonly awaitPromise?: boolean + readonly disableBreaks?: boolean + readonly replMode?: boolean + readonly allowUnsafeEvalBlockedByCSP?: boolean + readonly throwOnSideEffect?: boolean + readonly serializationOptions?: InspectorJsonObject + readonly timeoutMs?: number +} + +/** Property enumeration request for one backend object. */ +export interface RuntimeGetPropertiesRequest { + readonly handle: Handle + readonly ownProperties?: boolean + readonly accessorPropertiesOnly?: boolean + readonly generatePreview?: boolean + readonly nonIndexedPropertiesOnly?: boolean +} + +/** Function invocation request within one inspected realm. */ +export interface RuntimeCallFunctionRequest { + readonly functionDeclaration: string + readonly receiver?: Handle + readonly arguments?: readonly RuntimeCallArgument[] + readonly objectGroup?: string + readonly silent?: boolean + readonly returnByValue?: boolean + readonly generatePreview?: boolean + readonly userGesture?: boolean + readonly awaitPromise?: boolean + readonly throwOnSideEffect?: boolean + readonly serializationOptions?: InspectorJsonObject +} + +/** Promise-await request for one retained backend object. */ +export interface RuntimeAwaitPromiseRequest { + readonly promise: Handle + readonly returnByValue?: boolean + readonly generatePreview?: boolean +} + +/** Shared result of evaluation, function calls, and promise awaiting. */ +export interface RuntimeCompletion { + readonly result: RuntimeRemoteObject + readonly exceptionDetails?: RuntimeExceptionDetails +} + +/** Shared result of property enumeration. */ +export interface RuntimeProperties { + readonly properties: readonly RuntimePropertyDescriptor[] + readonly internalProperties?: readonly RuntimeInternalPropertyDescriptor[] + readonly privateProperties?: readonly RuntimePrivatePropertyDescriptor[] + readonly exceptionDetails?: RuntimeExceptionDetails +} diff --git a/packages/experimental/inspector/src/shared/cdp/property.ts b/packages/experimental/inspector/src/shared/cdp/property.ts new file mode 100644 index 0000000000..f8af5e04ce --- /dev/null +++ b/packages/experimental/inspector/src/shared/cdp/property.ts @@ -0,0 +1,31 @@ +/** Realm-neutral property descriptors returned by Runtime backends. */ + +import type { RuntimeRemoteObject } from './remote-object.ts' + +/** One JavaScript property descriptor returned without invoking accessors. */ +export interface RuntimePropertyDescriptor { + readonly name: string + readonly value?: RuntimeRemoteObject + readonly writable?: boolean + readonly get?: RuntimeRemoteObject + readonly set?: RuntimeRemoteObject + readonly configurable: boolean + readonly enumerable: boolean + readonly wasThrown?: boolean + readonly isOwn?: boolean + readonly symbol?: RuntimeRemoteObject +} + +/** One engine-owned property such as `[[Prototype]]`. */ +export interface RuntimeInternalPropertyDescriptor { + readonly name: string + readonly value?: RuntimeRemoteObject +} + +/** One engine private property exposed when a backend supports it. */ +export interface RuntimePrivatePropertyDescriptor { + readonly name: string + readonly value?: RuntimeRemoteObject + readonly get?: RuntimeRemoteObject + readonly set?: RuntimeRemoteObject +} diff --git a/packages/experimental/inspector/src/shared/cdp/realm.ts b/packages/experimental/inspector/src/shared/cdp/realm.ts new file mode 100644 index 0000000000..38b61afb11 --- /dev/null +++ b/packages/experimental/inspector/src/shared/cdp/realm.ts @@ -0,0 +1,157 @@ +/** Environment-independent backend interfaces for inspected JavaScript realms. */ + +import type { RuntimeBackendObjectHandle, RuntimeScriptKey } from './ids.ts' +import type { + RuntimeAwaitPromiseRequest, + RuntimeCallFunctionRequest, + RuntimeCompletion, + RuntimeConsoleBackendEvent, + RuntimeDebuggerEvent, + RuntimeDebuggerEnableRequest, + RuntimeDebuggerResumeRequest, + RuntimeCallFrameEvaluationRequest, + RuntimeEvaluateRequest, + RuntimeGetPropertiesRequest, + RuntimeProperties, + RuntimeScript, +} from './index.ts' + +/** Raw notification emitted by a native engine protocol backend. */ +export interface NativeProtocolNotification { + readonly method: string + readonly params?: Readonly> +} + +/** Explicitly supported or unsupported realm capability. */ +export type RealmCapability = + | { readonly state: 'supported'; readonly backend: Backend } + | { readonly state: 'unsupported'; readonly reason: string } + +/** Runtime operations implemented inside one per-connection realm session. */ +export interface RuntimeBackend { + /** Prepare Runtime events and execution state for this connection. */ + enable(): Promise + /** Disable Runtime events and release backend session state. */ + disable(): Promise + /** + * Evaluate source in this realm. + * @param request - Engine-independent evaluation request. + * @returns Completion containing a value or JavaScript exception. + */ + evaluate(request: RuntimeEvaluateRequest): Promise> + /** + * Enumerate one retained object's properties. + * @param request - Property request containing this backend's object handle. + * @returns Property descriptors and optional exception details. + */ + getProperties( + request: RuntimeGetPropertiesRequest, + ): Promise> + /** + * Invoke a function with references owned by this realm session. + * @param request - Function source, receiver, arguments, and result options. + * @returns Completion containing the invocation result or JavaScript exception. + */ + callFunction( + request: RuntimeCallFunctionRequest, + ): Promise> + /** + * Await one retained Promise. + * @param request - Promise handle and result options. + * @returns Completion containing the fulfilled value or rejection. + */ + awaitPromise( + request: RuntimeAwaitPromiseRequest, + ): Promise> + /** @returns Names visible in the realm's global lexical scope. */ + globalLexicalScopeNames(): Promise + /** + * Release one backend object reference. + * @param handle - Handle owned by this realm session. + */ + releaseObject(handle: RuntimeBackendObjectHandle): Promise + /** + * Release every backend object retained under one group. + * @param group - DevTools object-group name. + */ + releaseObjectGroup(group: string): Promise +} + +/** Realm Console event source. */ +export interface ConsoleBackend { + /** + * Subscribe to Console and uncaught-exception events. + * @param listener - Connection-local event consumer. + * @returns A disposer for the subscription. + */ + subscribe(listener: (event: RuntimeConsoleBackendEvent) => void): () => void + /** Clear backend-owned Console history when supported. */ + clear(): Promise +} + +/** Realm script catalog independent of CDP ScriptId allocation. */ +export interface SourceBackend { + /** @returns Every script currently known to this realm. */ + listScripts(): Promise + /** + * Read source text for one realm-local script key. + * @param scriptKey - Script identity allocated by this realm. + * @returns The complete source text. + */ + getScriptSource(scriptKey: RuntimeScriptKey): Promise + /** + * Read an optional source map for one realm-local script key. + * @param scriptKey - Script identity allocated by this realm. + * @returns Source-map JSON when one exists. + */ + getSourceMap(scriptKey: RuntimeScriptKey): Promise + /** + * Subscribe to scripts discovered after the initial catalog read. + * @param listener - Consumer of newly discovered scripts. + * @returns A disposer for the subscription. + */ + subscribe(listener: (script: RuntimeScript) => void): () => void +} + +/** Active JavaScript debugging backend for one realm session. */ +export interface DebuggerBackend { + /** Enable debugger events for this connection. */ + enable(request: RuntimeDebuggerEnableRequest): Promise>> + /** Disable debugger events for this connection. */ + disable(): Promise>> + /** Pause this realm. */ + pause(): Promise>> + /** Resume this realm. */ + resume(request: RuntimeDebuggerResumeRequest): Promise>> + /** + * Evaluate an expression in one paused frame. + * @param request - Frame identity, expression, and result options. + * @returns A common Runtime completion. + */ + evaluateOnCallFrame( + request: RuntimeCallFrameEvaluationRequest, + ): Promise> + /** + * Subscribe to paused, resumed, and breakpoint events. + * @param listener - Connection-local debugger event consumer. + * @returns A disposer removing the consumer. + */ + subscribe(listener: (event: RuntimeDebuggerEvent) => void): () => void +} + +/** Explicit Host-only native protocol adapter for domains not yet normalized. */ +export interface NativeDomainBackend { + /** + * Execute one native protocol request. + * @param method - CDP method owned by the native engine. + * @param params - Parsed CDP parameters. + * @returns Native response fields. + */ + request(method: string, params: Readonly>): Promise>> + /** + * Subscribe to native protocol notifications. + * @param listener - Notification consumer. + * @returns A disposer removing the consumer. + */ + subscribe(listener: (message: NativeProtocolNotification) => void): () => void +} diff --git a/packages/experimental/inspector/src/shared/cdp/remote-object.ts b/packages/experimental/inspector/src/shared/cdp/remote-object.ts new file mode 100644 index 0000000000..4313f1f0c2 --- /dev/null +++ b/packages/experimental/inspector/src/shared/cdp/remote-object.ts @@ -0,0 +1,78 @@ +/** Realm-neutral JavaScript value descriptions used by Inspector backends. */ + +import type { InspectorObjectReference } from '../cordis/object-reference.ts' +import type { InspectorJsonValue } from '../json.ts' + +/** Runtime value kinds represented by CDP `Runtime.RemoteObject`. */ +export type RuntimeRemoteObjectType = + | 'object' + | 'function' + | 'undefined' + | 'string' + | 'number' + | 'boolean' + | 'symbol' + | 'bigint' + +/** Runtime object subtype hints understood by Chrome DevTools. */ +export type RuntimeRemoteObjectSubtype = + | 'array' + | 'null' + | 'node' + | 'regexp' + | 'date' + | 'map' + | 'set' + | 'weakmap' + | 'weakset' + | 'iterator' + | 'generator' + | 'error' + | 'proxy' + | 'promise' + | 'typedarray' + | 'arraybuffer' + | 'dataview' + | 'webassemblymemory' + | 'wasmvalue' + +/** Shallow property rendered inline by DevTools. */ +export interface RuntimePropertyPreview { + readonly name: string + readonly type: RuntimeRemoteObjectType | 'accessor' + readonly value?: string + readonly valuePreview?: RuntimeObjectPreview + readonly subtype?: RuntimeRemoteObjectSubtype +} + +/** Shallow object rendering that never carries a live-object reference. */ +export interface RuntimeObjectPreview { + readonly type: RuntimeRemoteObjectType + readonly subtype?: RuntimeRemoteObjectSubtype + readonly description?: string + readonly overflow: boolean + readonly properties: readonly RuntimePropertyPreview[] +} + +/** Engine-independent description of one JavaScript value. */ +export interface RuntimeRemoteObjectDescriptor { + readonly type: RuntimeRemoteObjectType + readonly subtype?: RuntimeRemoteObjectSubtype + readonly className?: string + readonly value?: InspectorJsonValue + readonly unserializableValue?: string + readonly description?: string + readonly preview?: RuntimeObjectPreview +} + +/** Backend-owned reference to a retained object in one realm session. */ +export interface RuntimeBackendObjectReference { + readonly handle: Handle +} + +/** Realm-neutral value plus optional backend and Cordis identities. */ +export interface RuntimeRemoteObject { + readonly descriptor: RuntimeRemoteObjectDescriptor + readonly object?: RuntimeBackendObjectReference + readonly semanticReference?: InspectorObjectReference +} diff --git a/packages/experimental/inspector/src/shared/cdp/sources.ts b/packages/experimental/inspector/src/shared/cdp/sources.ts new file mode 100644 index 0000000000..22b601bc76 --- /dev/null +++ b/packages/experimental/inspector/src/shared/cdp/sources.ts @@ -0,0 +1,19 @@ +/** Realm-neutral script metadata used by source backends. */ + +import type { RuntimeScriptKey } from './ids.ts' + +/** One script visible in a realm's source catalog. */ +export interface RuntimeScript { + readonly scriptKey: RuntimeScriptKey + readonly url: string + readonly hash: string + readonly buildId?: string + readonly sourceMapUrl?: string + readonly startLine: number + readonly startColumn: number + readonly endLine: number + readonly endColumn: number + readonly executionContextId?: number + readonly isModule?: boolean + readonly length?: number +} diff --git a/packages/experimental/inspector/src/shared/identity.ts b/packages/experimental/inspector/src/shared/identity.ts new file mode 100644 index 0000000000..3983e03f25 --- /dev/null +++ b/packages/experimental/inspector/src/shared/identity.ts @@ -0,0 +1,19 @@ +/** Shared branded-identifier construction without assigning protocol ownership. */ + +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** String branded with one Inspector identity role. */ +export type InspectorId = Branded + +/** + * Validate and brand a non-empty identifier received from or sent across a runtime boundary. + * @param value - Untrusted identifier text. + * @param label - Field name used in validation errors. + * @returns The role-branded identifier. + */ +export function inspectorId(value: string, label: string): InspectorId { + if (value.length === 0 || value.length > 256) { + throw new Error(`inspector protocol: ${label} must contain 1 to 256 characters`) + } + return value as InspectorId +} diff --git a/packages/experimental/inspector/src/shared/index.ts b/packages/experimental/inspector/src/shared/index.ts new file mode 100644 index 0000000000..25d761878f --- /dev/null +++ b/packages/experimental/inspector/src/shared/index.ts @@ -0,0 +1,18 @@ +/** Environment-independent Inspector models and bridge protocol exports. */ + +export * from './bridge/messages/control.ts' +export * from './bridge/control-codec.ts' +export * from './cordis/snapshot.ts' +export * from './bridge/messages/cordis.ts' +export * from './bridge/messages/runtime/index.ts' +export * from './bridge/messages/sources/index.ts' +export * from './bridge/messages/network.ts' +export * from './network/observation.ts' +export * from './bridge/ids.ts' +export * from './json.ts' +export * from './cordis/object-reference.ts' +export * from './bridge/messages/query/index.ts' +export * from './bridge/query-reader.ts' +export * from './bridge/rpc.ts' +export * from './cdp/index.ts' +export * from './bridge/messages/observation.ts' diff --git a/packages/experimental/inspector/src/shared/json.ts b/packages/experimental/inspector/src/shared/json.ts new file mode 100644 index 0000000000..07d048a402 --- /dev/null +++ b/packages/experimental/inspector/src/shared/json.ts @@ -0,0 +1,79 @@ +/** JSON values admitted by every Inspector cross-realm message. */ + +/** JSON scalar accepted by Inspector transports. */ +export type InspectorJsonPrimitive = null | boolean | number | string + +/** Recursively JSON-compatible value accepted by Inspector transports. */ +export type InspectorJsonValue = + | InspectorJsonPrimitive + | readonly InspectorJsonValue[] + | InspectorJsonObject + +/** JSON-compatible object accepted by Inspector transports. */ +export interface InspectorJsonObject { + readonly [key: string]: InspectorJsonValue +} + +/** + * Test that a value can cross both MessagePort and JSON WebSocket carriers without coercion. + * @param value - Candidate wire value. + * @returns Whether the value is lossless JSON data. + */ +export function isJsonValue(value: unknown): value is InspectorJsonValue { + return visitJson(value, new Set()) +} + +/** + * Require a plain JSON object and return it with a narrowed type. + * @param value - Candidate wire value. + * @param label - Field name used in validation errors. + * @returns The validated JSON object. + */ +export function requireJsonObject(value: unknown, label: string): InspectorJsonObject { + if (!isPlainObject(value) || !isJsonValue(value)) { + throw new Error(`inspector protocol: ${label} must be a JSON object`) + } + return value +} + +/** + * Compute the UTF-8 byte length of a JSON wire value. + * @param value - Validated JSON value. + * @returns Its encoded byte length. + */ +export function jsonByteLength(value: InspectorJsonValue): number { + return new TextEncoder().encode(JSON.stringify(value)).byteLength +} + +/** + * Test whether a value is a plain object with string own keys. + * @param value - Candidate object. + * @returns Whether the value has `Object.prototype` or a null prototype. + */ +export function isPlainObject(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + const prototype = Reflect.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null +} + +function visitJson(value: unknown, ancestors: Set): value is InspectorJsonValue { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return true + if (typeof value === 'number') return Number.isFinite(value) && !Object.is(value, -0) + if (typeof value !== 'object' || ancestors.has(value)) return false + ancestors.add(value) + try { + if (Array.isArray(value)) { + if (Object.getPrototypeOf(value) !== Array.prototype || Reflect.ownKeys(value).length !== value.length + 1) return false + return value.every(item => visitJson(item, ancestors)) + } + if (!isPlainObject(value)) return false + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string') return false + const descriptor = Object.getOwnPropertyDescriptor(value, key) + if (descriptor?.enumerable !== true || !('value' in descriptor) || !visitJson(descriptor.value, ancestors)) return false + } + return true + } finally { + ancestors.delete(value) + } +} diff --git a/packages/experimental/inspector/src/shared/validation.ts b/packages/experimental/inspector/src/shared/validation.ts new file mode 100644 index 0000000000..d68d6944f0 --- /dev/null +++ b/packages/experimental/inspector/src/shared/validation.ts @@ -0,0 +1,93 @@ +/** Shared exact-object readers for versioned Inspector wire protocols. */ + +import { inspectorId, type InspectorId } from './identity.ts' +import { isPlainObject } from './json.ts' + +/** + * Require a plain object containing only the listed fields. + * @param value - Candidate object. + * @param keys - Complete field allowlist. + * @param label - Object name used in validation errors. + * @returns The validated plain object. + */ +export function exactObject(value: unknown, keys: readonly string[], label: string): Record { + if (!isPlainObject(value)) throw new Error(`inspector protocol: ${label} must be an object`) + exactKeys(value, keys, label) + return value +} + +/** + * Reject fields outside one versioned object's declared field set. + * @param value - Plain object being validated. + * @param keys - Complete field allowlist. + * @param label - Object name used in validation errors. + */ +export function exactKeys(value: Record, keys: readonly string[], label: string): void { + const allowed = new Set(keys) + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string' || !allowed.has(key)) { + throw new Error(`inspector protocol: ${label} has unknown field ${JSON.stringify(String(key))}`) + } + } +} + +/** + * Read one non-empty opaque identifier. + * @param value - Candidate identifier. + * @param label - Field name used in validation errors. + * @returns The role-branded identifier. + */ +export function wireId(value: unknown, label: string): InspectorId { + if (typeof value !== 'string') throw new Error(`inspector protocol: ${label} must be a string`) + return inspectorId(value, label) +} + +/** + * Read one optional string field. + * @param value - Object containing the field. + * @param key - Field name. + * @returns An empty object or the validated field. + */ +export function optionalString( + value: Record, + key: Key, +): { readonly [Property in Key]?: string } { + const item = value[key] + if (item === undefined) return {} + if (typeof item !== 'string') throw new Error(`inspector protocol: ${key} must be a string`) + return { [key]: item } as { readonly [Property in Key]?: string } +} + +/** + * Read one optional boolean field. + * @param value - Object containing the field. + * @param key - Field name. + * @returns An empty object or the validated field. + */ +export function optionalBoolean( + value: Record, + key: Key, +): { readonly [Property in Key]?: boolean } { + const item = value[key] + if (item === undefined) return {} + if (typeof item !== 'boolean') throw new Error(`inspector protocol: ${key} must be a boolean`) + return { [key]: item } as { readonly [Property in Key]?: boolean } +} + +/** + * Read one optional non-negative finite number field. + * @param value - Object containing the field. + * @param key - Field name. + * @returns An empty object or the validated field. + */ +export function optionalNonNegativeNumber( + value: Record, + key: Key, +): { readonly [Property in Key]?: number } { + const item = value[key] + if (item === undefined) return {} + if (typeof item !== 'number' || !Number.isFinite(item) || item < 0) { + throw new Error(`inspector protocol: ${key} must be a non-negative finite number`) + } + return { [key]: item } as { readonly [Property in Key]?: number } +} diff --git a/packages/experimental/inspector/tests/protocol.host.spec.ts b/packages/experimental/inspector/tests/protocol.host.spec.ts new file mode 100644 index 0000000000..1e538194e5 --- /dev/null +++ b/packages/experimental/inspector/tests/protocol.host.spec.ts @@ -0,0 +1,265 @@ +/** Worker and shared protocol behavior. */ + +import { describe, expect, it, vi } from 'vitest' +import { INSPECTOR_PROTOCOL_VERSION, parseSourceFrame, parseWorkerSourceFrame } from '../src/shared/bridge/messages/observation.ts' +import { InspectorSourceRegistry, type InspectorRecordConsumer, type SourceConnection } from '../src/worker/bridge/hub.ts' + +describe('Inspector source protocol', () => { + it('rebuilds a valid source frame and rejects non-JSON payloads', () => { + const frame = parseSourceFrame({ + v: INSPECTOR_PROTOCOL_VERSION, + t: 'source/append', + sourceId: 'host-1', + generation: 'generation-1', + firstSequence: 1, + droppedBefore: 0, + records: [{ monotonicMs: 12, topic: 'probe', payload: { ok: true } }], + }, 4) + expect(frame.t).toBe('source/append') + expect(() => parseSourceFrame({ + v: INSPECTOR_PROTOCOL_VERSION, + t: 'source/append', + sourceId: 'host-1', + generation: 'generation-1', + firstSequence: 1, + droppedBefore: 0, + records: [{ monotonicMs: 12, topic: 'probe', payload: { bad: undefined } }], + }, 4)).toThrow('lossless JSON object') + }) + + it('isolates generations and reports sequence gaps', () => { + const replace = vi.fn() + const append = vi.fn() + const close = vi.fn() + const consumer: InspectorRecordConsumer = { + topics: new Set(['probe']), + replace, + append, + close, + } + const replies: unknown[] = [] + const send = vi.fn((frame: unknown) => { replies.push(frame) }) + const closeConnection = vi.fn() + const connection: SourceConnection = { + kind: 'host', + send, + close: closeConnection, + } + const registry = new InspectorSourceRegistry([consumer], 16_384, 4) + registry.receive(connection, { + v: 0, + t: 'source/open', + source: { + sourceId: 'host-1', + generation: 'g-1', + kind: 'host', + label: 'Host', + timeOriginMs: 1_000, + capabilities: [], + }, + topics: ['probe'], + }) + registry.receive(connection, { + v: 0, + t: 'source/append', + sourceId: 'host-1', + generation: 'g-1', + firstSequence: 2, + droppedBefore: 1, + records: [{ monotonicMs: 1, topic: 'probe', payload: { value: 1 } }], + }) + + expect(append).toHaveBeenCalledOnce() + expect(registry.describe()[0]).toMatchObject({ expectedSequence: 3, dropped: 1, topics: { probe: 1 } }) + + registry.receive(connection, { + v: 0, + t: 'source/append', + sourceId: 'host-1', + generation: 'g-1', + firstSequence: 5, + droppedBefore: 0, + records: [], + }) + expect(replies.at(-1)).toMatchObject({ t: 'source/resnapshot', expectedSequence: 3 }) + expect(append).toHaveBeenCalledOnce() + }) + + it('closes only a malformed source connection', () => { + const send = vi.fn() + const closeConnection = vi.fn() + const connection: SourceConnection = { + kind: 'client', + send, + close: closeConnection, + } + const registry = new InspectorSourceRegistry([], 1_024, 2) + registry.receive(connection, { v: 99, t: 'source/open' }) + expect(send).toHaveBeenCalledWith(expect.objectContaining({ t: 'source/rejected' })) + expect(closeConnection).toHaveBeenCalledOnce() + }) + + it('decodes Runtime commands and rejects undeclared fields', () => { + const request = parseWorkerSourceFrame({ + v: 0, + t: 'client-runtime/request', + sourceId: 'client-1', + generation: 'g-1', + sessionId: 'session-1', + requestId: 'request-1', + command: { + op: 'call-function', + functionDeclaration: 'function () { return this.value }', + receiver: 'object-1', + arguments: [{ kind: 'unserializable', value: 'NaN' }], + returnByValue: true, + }, + }) + expect(request).toMatchObject({ + t: 'client-runtime/request', + command: { op: 'call-function', receiver: 'object-1', returnByValue: true }, + }) + if (request.t !== 'client-runtime/request') throw new Error('unexpected frame type') + expect(() => parseWorkerSourceFrame({ + ...request, + command: { ...request.command, unversionedExtension: true }, + })).toThrow('unknown field') + }) + + it('rejects invalid RemoteObject representations', () => { + expect(() => parseSourceFrame({ + v: 0, + t: 'client-runtime/response', + sourceId: 'client-1', + generation: 'g-1', + sessionId: 'session-1', + requestId: 'request-1', + outcome: { + ok: true, + result: { + op: 'evaluate', + completion: { + result: { + descriptor: { type: 'number', value: 1 }, + object: { handle: 'object-1' }, + }, + }, + }, + }, + }, 4)).toThrow('invalid number RemoteObject representation') + }) + + it('decodes exact Client Console lifecycle and event frames', () => { + expect(parseWorkerSourceFrame({ + v: 0, + t: 'client-console/enable', + sourceId: 'client-1', + generation: 'g-1', + sessionId: 'session-1', + })).toMatchObject({ t: 'client-console/enable', sessionId: 'session-1' }) + + const frame = parseSourceFrame({ + v: 0, + t: 'client-console/event', + sourceId: 'client-1', + generation: 'g-1', + sessionId: 'session-1', + event: { + type: 'console-api', + event: { + type: 'log', + arguments: [{ + descriptor: { type: 'object', className: 'Object', description: 'Object' }, + object: { handle: 'object-1' }, + }], + timestamp: 12, + }, + }, + }, 4) + expect(frame).toMatchObject({ + t: 'client-console/event', + sessionId: 'session-1', + event: { + type: 'console-api', + event: { type: 'log', arguments: [{ object: { handle: 'object-1' } }] }, + }, + }) + + expect(() => parseWorkerSourceFrame({ + v: 0, + t: 'client-console/disable', + sourceId: 'client-1', + generation: 'g-1', + sessionId: 'session-1', + extra: true, + })).toThrow('unknown field') + }) + + it('decodes bounded Client source commands and responses', () => { + expect(parseWorkerSourceFrame({ + v: 0, + t: 'client-sources/request', + sourceId: 'client-1', + generation: 'g-1', + sessionId: 'source-session-1', + requestId: 'source-request-1', + command: { + op: 'get-content-chunk', + scriptKey: 'bundle', + content: 'source', + offset: 0, + maxBytes: 1024, + }, + })).toMatchObject({ + t: 'client-sources/request', + command: { op: 'get-content-chunk', maxBytes: 1024 }, + }) + + expect(parseSourceFrame({ + v: 0, + t: 'client-sources/response', + sourceId: 'client-1', + generation: 'g-1', + sessionId: 'source-session-1', + requestId: 'source-request-1', + outcome: { + ok: true, + result: { + op: 'get-content-chunk', + scriptKey: 'bundle', + content: 'source', + available: true, + offset: 0, + nextOffset: 3, + data: 'YWJj', + eof: true, + }, + }, + }, 4)).toMatchObject({ + t: 'client-sources/response', + outcome: { ok: true, result: { data: 'YWJj', eof: true } }, + }) + + expect(() => parseSourceFrame({ + v: 0, + t: 'client-sources/response', + sourceId: 'client-1', + generation: 'g-1', + sessionId: 'source-session-1', + requestId: 'source-request-1', + outcome: { + ok: true, + result: { + op: 'get-content-chunk', + scriptKey: 'bundle', + content: 'source', + available: true, + offset: 0, + nextOffset: 3, + data: 'not base64', + eof: true, + }, + }, + }, 4)).toThrow('chunk data') + }) +}) diff --git a/packages/experimental/inspector/tests/source-buffer.host.spec.ts b/packages/experimental/inspector/tests/source-buffer.host.spec.ts new file mode 100644 index 0000000000..41907763f4 --- /dev/null +++ b/packages/experimental/inspector/tests/source-buffer.host.spec.ts @@ -0,0 +1,43 @@ +/** Worker-side source buffer behavior. */ + +import { describe, expect, it } from 'vitest' +import { inspectorId } from '../src/shared/bridge/ids.ts' +import { InspectorSourceBuffer } from '../src/shared/bridge/buffer.ts' + +const sourceId = inspectorId<'InspectorSourceId'>('source-buffer-test', 'sourceId') +const generation = inspectorId<'InspectorSourceGeneration'>('generation-buffer-test', 'generation') + +function buffer(maxQueuedRecords = 2): InspectorSourceBuffer { + return new InspectorSourceBuffer({ + topics: ['*'], + maxQueuedRecords, + maxQueuedBytes: 32_768, + maxRecordsPerFrame: 8, + maxFrameBytes: 32_768, + }) +} + +describe('Inspector source buffer', () => { + it('absorbs pre-replacement queue loss exactly once', () => { + const records = buffer(1) + records.publish('test/event', { ordinal: 1 }, 1) + records.publish('test/event', { ordinal: 2 }, 2) + + expect(records.replacement(sourceId, generation)).toMatchObject({ + nextSequence: 2, + records: [], + }) + expect(records.takeBatch(sourceId, generation)).toMatchObject({ + firstSequence: 2, + droppedBefore: 0, + records: [{ topic: 'test/event', payload: { ordinal: 2 } }], + }) + }) + + it('validates records before either carrier can enqueue them', () => { + const records = buffer() + + expect(() => { records.publish('', {}, 1) }).toThrow('topic must contain 1 to 128 characters') + expect(() => { records.publish('test/event', {}, Number.NaN) }).toThrow('monotonicMs must be finite') + }) +}) diff --git a/packages/experimental/inspector/tsconfig.client.json b/packages/experimental/inspector/tsconfig.client.json new file mode 100644 index 0000000000..402c7f4c6a --- /dev/null +++ b/packages/experimental/inspector/tsconfig.client.json @@ -0,0 +1,96 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "files": [ + "src/client/bridge/controller.ts", + "src/client/bridge/dispatcher.ts", + "src/client/bridge/lifecycle.ts", + "src/client/bridge/publisher.ts", + "src/client/bridge/rpc.ts", + "src/client/bridge/transport.ts", + "src/client/cdp/console.ts", + "src/client/cdp/debugger.ts", + "src/client/cdp/errors.ts", + "src/client/cdp/heap-profiler.ts", + "src/client/cdp/index.ts", + "src/client/cdp/objects.ts", + "src/client/cdp/profiler.ts", + "src/client/cdp/properties.ts", + "src/client/cdp/runtime.ts", + "src/client/cdp/sources.ts", + "src/client/cdp/stack.ts", + "src/client/index.ts", + "src/client/inspection/cordis.ts", + "src/client/inspection/network.ts", + "src/client/inspection/realm.ts", + "src/client/plugin.ts", + "src/shared/bridge/buffer.ts", + "src/shared/bridge/codec.ts", + "src/shared/bridge/control-codec.ts", + "src/shared/bridge/ids.ts", + "src/shared/bridge/messages/control.ts", + "src/shared/bridge/messages/cordis.ts", + "src/shared/bridge/messages/network.ts", + "src/shared/bridge/messages/observation.ts", + "src/shared/bridge/messages/query/codec.ts", + "src/shared/bridge/messages/query/commands.ts", + "src/shared/bridge/messages/query/frames.ts", + "src/shared/bridge/messages/query/index.ts", + "src/shared/bridge/messages/runtime/command-codec.ts", + "src/shared/bridge/messages/runtime/commands.ts", + "src/shared/bridge/messages/runtime/console-frames.ts", + "src/shared/bridge/messages/runtime/frames.ts", + "src/shared/bridge/messages/runtime/index.ts", + "src/shared/bridge/messages/runtime/value-codec.ts", + "src/shared/bridge/messages/sources/codec.ts", + "src/shared/bridge/messages/sources/commands.ts", + "src/shared/bridge/messages/sources/frames.ts", + "src/shared/bridge/messages/sources/index.ts", + "src/shared/bridge/publisher.ts", + "src/shared/bridge/query-reader.ts", + "src/shared/bridge/rpc.ts", + "src/shared/bridge/validation.ts", + "src/shared/bridge/version.ts", + "src/shared/cdp/capabilities.ts", + "src/shared/cdp/console.ts", + "src/shared/cdp/debugger.ts", + "src/shared/cdp/errors.ts", + "src/shared/cdp/ids.ts", + "src/shared/cdp/index.ts", + "src/shared/cdp/operations.ts", + "src/shared/cdp/property.ts", + "src/shared/cdp/realm.ts", + "src/shared/cdp/remote-object.ts", + "src/shared/cdp/sources.ts", + "src/shared/cordis/collector.ts", + "src/shared/cordis/ids.ts", + "src/shared/cordis/model.ts", + "src/shared/cordis/object-reference.ts", + "src/shared/cordis/object-registry.ts", + "src/shared/cordis/observer.ts", + "src/shared/cordis/projector.ts", + "src/shared/cordis/reader.ts", + "src/shared/cordis/snapshot.ts", + "src/shared/identity.ts", + "src/shared/index.ts", + "src/shared/json.ts", + "src/shared/network/observation.ts", + "src/shared/service.ts", + "src/shared/validation.ts" + ], + "references": [ + { + "path": "../../util/brand" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../util/crypto" + } + ] +} diff --git a/packages/experimental/inspector/tsconfig.host.json b/packages/experimental/inspector/tsconfig.host.json new file mode 100644 index 0000000000..84235269cd --- /dev/null +++ b/packages/experimental/inspector/tsconfig.host.json @@ -0,0 +1,155 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "files": [ + "src/host/bridge/controller.ts", + "src/host/bridge/dispatcher.ts", + "src/host/bridge/lifecycle.ts", + "src/host/bridge/publisher.ts", + "src/host/bridge/rpc.ts", + "src/host/bridge/transport.ts", + "src/host/cdp/console.ts", + "src/host/cdp/debugger.ts", + "src/host/cdp/errors.ts", + "src/host/cdp/heap-profiler.ts", + "src/host/cdp/index.ts", + "src/host/cdp/objects.ts", + "src/host/cdp/profiler.ts", + "src/host/cdp/properties.ts", + "src/host/cdp/runtime.ts", + "src/host/cdp/sources.ts", + "src/host/cdp/stack.ts", + "src/host/index.ts", + "src/host/inspection/cordis.ts", + "src/host/inspection/network.ts", + "src/host/inspection/realm.ts", + "src/host/plugin.ts", + "src/index.ts", + "src/invariant.ts", + "src/shared/bridge/buffer.ts", + "src/shared/bridge/codec.ts", + "src/shared/bridge/control-codec.ts", + "src/shared/bridge/ids.ts", + "src/shared/bridge/messages/control.ts", + "src/shared/bridge/messages/cordis.ts", + "src/shared/bridge/messages/network.ts", + "src/shared/bridge/messages/observation.ts", + "src/shared/bridge/messages/query/codec.ts", + "src/shared/bridge/messages/query/commands.ts", + "src/shared/bridge/messages/query/frames.ts", + "src/shared/bridge/messages/query/index.ts", + "src/shared/bridge/messages/runtime/command-codec.ts", + "src/shared/bridge/messages/runtime/commands.ts", + "src/shared/bridge/messages/runtime/console-frames.ts", + "src/shared/bridge/messages/runtime/frames.ts", + "src/shared/bridge/messages/runtime/index.ts", + "src/shared/bridge/messages/runtime/value-codec.ts", + "src/shared/bridge/messages/sources/codec.ts", + "src/shared/bridge/messages/sources/commands.ts", + "src/shared/bridge/messages/sources/frames.ts", + "src/shared/bridge/messages/sources/index.ts", + "src/shared/bridge/publisher.ts", + "src/shared/bridge/query-reader.ts", + "src/shared/bridge/rpc.ts", + "src/shared/bridge/validation.ts", + "src/shared/bridge/version.ts", + "src/shared/cdp/capabilities.ts", + "src/shared/cdp/console.ts", + "src/shared/cdp/debugger.ts", + "src/shared/cdp/errors.ts", + "src/shared/cdp/ids.ts", + "src/shared/cdp/index.ts", + "src/shared/cdp/operations.ts", + "src/shared/cdp/property.ts", + "src/shared/cdp/realm.ts", + "src/shared/cdp/remote-object.ts", + "src/shared/cdp/sources.ts", + "src/shared/cordis/collector.ts", + "src/shared/cordis/ids.ts", + "src/shared/cordis/model.ts", + "src/shared/cordis/object-reference.ts", + "src/shared/cordis/object-registry.ts", + "src/shared/cordis/observer.ts", + "src/shared/cordis/projector.ts", + "src/shared/cordis/reader.ts", + "src/shared/cordis/snapshot.ts", + "src/shared/identity.ts", + "src/shared/index.ts", + "src/shared/json.ts", + "src/shared/network/observation.ts", + "src/shared/service.ts", + "src/shared/validation.ts", + "src/worker/bridge/endpoint.ts", + "src/worker/bridge/hub.ts", + "src/worker/bridge/runtime-rpc.ts", + "src/worker/bridge/session.ts", + "src/worker/bridge/source-rpc.ts", + "src/worker/cdp/domains/debugger/cdp-params.ts", + "src/worker/cdp/domains/debugger/index.ts", + "src/worker/cdp/domains/debugger/projector.ts", + "src/worker/cdp/domains/debugger/script-registry.ts", + "src/worker/cdp/domains/debugger/session.ts", + "src/worker/cdp/domains/dom/index.ts", + "src/worker/cdp/domains/dom/model.ts", + "src/worker/cdp/domains/dom/session.ts", + "src/worker/cdp/domains/native.ts", + "src/worker/cdp/domains/network/session.ts", + "src/worker/cdp/domains/runtime/cdp-params.ts", + "src/worker/cdp/domains/runtime/index.ts", + "src/worker/cdp/domains/runtime/object-table.ts", + "src/worker/cdp/domains/runtime/session.ts", + "src/worker/cdp/ids.ts", + "src/worker/cdp/protocol.ts", + "src/worker/cdp/realm-sessions.ts", + "src/worker/cdp/session.ts", + "src/worker/cdp/target.ts", + "src/worker/entry.ts", + "src/worker/inspection/cordis-query.ts", + "src/worker/inspection/cordis-store.ts", + "src/worker/inspection/network-store.ts", + "src/worker/inspection/query-router.ts", + "src/worker/inspection/realm-store.ts", + "src/worker/inspection/realm.ts", + "src/worker/realms/client/bridge.ts", + "src/worker/realms/client/console.ts", + "src/worker/realms/client/debugger.ts", + "src/worker/realms/client/index.ts", + "src/worker/realms/client/runtime.ts", + "src/worker/realms/client/scripts.ts", + "src/worker/realms/client/sources.ts", + "src/worker/realms/client/values.ts", + "src/worker/realms/host/bridge.ts", + "src/worker/realms/host/console.ts", + "src/worker/realms/host/debugger.ts", + "src/worker/realms/host/index.ts", + "src/worker/realms/host/runtime.ts", + "src/worker/realms/host/scripts.ts", + "src/worker/realms/host/sources.ts", + "src/worker/realms/host/values.ts", + "src/worker/server.ts" + ], + "references": [ + { + "path": "../../util/brand" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../host/webserver" + }, + { + "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../util/crypto" + } + ] +} diff --git a/packages/experimental/inspector/tsconfig.json b/packages/experimental/inspector/tsconfig.json new file mode 100644 index 0000000000..2eca820546 --- /dev/null +++ b/packages/experimental/inspector/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.host.json" + }, + { + "path": "./tsconfig.client.json" + } + ] +} diff --git a/packages/experimental/inspector/tsdown.config.ts b/packages/experimental/inspector/tsdown.config.ts new file mode 100644 index 0000000000..349255c09b --- /dev/null +++ b/packages/experimental/inspector/tsdown.config.ts @@ -0,0 +1,22 @@ +import type { UserConfig } from 'tsdown' +import { clientBundle } from '../../client/tsdown.client.ts' + +const worker: UserConfig = { + entry: { worker: 'lib/types/worker/entry.js' }, + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + outputOptions: { inlineDynamicImports: true }, + deps: { neverBundle: specifier => specifier === 'ws' }, +} + +/** Build the Host plugin and Worker during the Host pass, and the dynamic Client plugin during the Client pass. */ +export default clientBundle( + '@deepseek-ai/dsh-experimental-inspector', + ['lib/types/index.js', 'lib/types/invariant.js'], + { hostPhase: true, companions: [worker] }, +) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3eb8a2e9cf..8eb529f007 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4794,6 +4794,49 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + packages/experimental/inspector: + dependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-client-modules': + specifier: workspace:^ + version: link:../../client/modules + '@deepseek-ai/dsh-util-crypto': + specifier: workspace:^ + version: link:../../util/crypto + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + ws: + specifier: ^8.21.0 + version: 8.21.0 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-host-webserver': + specifier: workspace:^ + version: link:../../host/webserver + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + playwright: + specifier: ^1.49.0 + version: 1.61.1 + tsx: + specifier: ^4.19.2 + version: 4.22.4 + packages/experimental/tool-agent-team: dependencies: '@deepseek-ai/schemastery': diff --git a/tsconfig.base.json b/tsconfig.base.json index 83c2432053..374f07f58f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -125,6 +125,7 @@ "@deepseek-ai/dsh-experimental-tool-agent-team/invariant": ["./packages/experimental/tool-agent-team/src/invariant.ts"], "@deepseek-ai/dsh-experimental-webworker-runtime/invariant": ["./packages/experimental/webworker-runtime/src/invariant.ts"], "@deepseek-ai/dsh-experimental-webworker-packer/invariant": ["./packages/experimental/webworker-packer/src/invariant.ts"], + "@deepseek-ai/dsh-experimental-inspector/invariant": ["./packages/experimental/inspector/src/invariant.ts"], "@deepseek-ai/dsh-util-crypto/invariant": ["./packages/util/crypto/src/invariant.ts"], "@deepseek-ai/dsh-*/invariant": [ "./packages/core/*/src/invariant.ts", @@ -271,6 +272,8 @@ "@deepseek-ai/dsh-experimental-tool-agent-team": ["./packages/experimental/tool-agent-team/src"], "@deepseek-ai/dsh-experimental-webworker-runtime": ["./packages/experimental/webworker-runtime/src"], "@deepseek-ai/dsh-experimental-webworker-packer": ["./packages/experimental/webworker-packer/src"], + "@deepseek-ai/dsh-experimental-inspector": ["./packages/experimental/inspector/src"], + "@deepseek-ai/dsh-experimental-inspector/client": ["./packages/experimental/inspector/src/client/index.ts"], "@deepseek-ai/dsh-util-crypto": ["./packages/util/crypto/src"], "@deepseek-ai/dsh-*": [ "./packages/core/*/src", diff --git a/tsconfig.client.json b/tsconfig.client.json index cdba4c13a6..bd8d98e4b8 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -51,6 +51,7 @@ { "path": "./packages/client/ui-attachment" }, { "path": "./packages/client/ui-primitives" }, { "path": "./packages/client/modules" }, + { "path": "./packages/experimental/inspector/tsconfig.client.json" }, { "path": "./packages/client/hmr" }, { "path": "./packages/client/connection/tsconfig.client.json" }, { "path": "./packages/typert/registry" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index b91be8e6ca..d4cd66e776 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -276,6 +276,7 @@ { "path": "./packages/test-support/loader-smoke" }, { "path": "./packages/test-support/llm-mock-server" }, { "path": "./packages/experimental/webworker-packer" }, + { "path": "./packages/experimental/inspector/tsconfig.host.json" }, { "path": "./packages/subagent/subagent" }, { "path": "./packages/subagent/tool-subagent" }, { "path": "./packages/subagent/tool-subagent-control" }, diff --git a/vitest.config.ts b/vitest.config.ts index 7374c355a6..c0e346c1c4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -251,6 +251,12 @@ export default defineConfig({ // coverage lane exists. 'packages/experimental/webworker-runtime/src/**', 'packages/experimental/webworker-packer/src/*', + // Inspector behavior spans a Node Worker, the Host isolate, and a real + // browser realm. Its focused specs cover pure logic, while its Worker, + // Debugger, Chromium, and Loader suites run the assembled paths that + // the parent Vitest process cannot attribute. TODO(inspector): remove + // when the coverage lane can merge cross-realm V8 coverage. + 'packages/experimental/inspector/src/**', 'packages/client/modules/src/client/system.ts', 'packages/client/hmr/src/client/index.ts', // Web config-tree boot round: the new host-side web-transport halves diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index e28e70e765..530a32d745 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -43,7 +43,10 @@ export default defineConfig({ // apps/cli only, not apps/*: apps/web/tests/*.e2e.ts needs the built // frontend dist and runs under vitest.web.config.ts (the test:web job). include: ['packages/*/*/tests/**/*.e2e.ts', 'apps/cli/tests/**/*.e2e.ts'], - exclude: ['**/*.expected.e2e.ts'], + exclude: [ + '**/*.expected.e2e.ts', + 'packages/experimental/inspector/tests/client-browser.e2e.ts', + ], // Real model calls: generous timeouts, and retries for transient flakes // (the shared internal key hits concurrency quotas). No coverage — the // unit suites own the coverage gate. diff --git a/vitest.web.config.ts b/vitest.web.config.ts index 7c20ab6462..ee8ed8be34 100644 --- a/vitest.web.config.ts +++ b/vitest.web.config.ts @@ -26,6 +26,7 @@ export default defineConfig({ include: [ 'apps/web/tests/**/*.e2e.ts', 'apps/web/tests/**/*.snapshot.ts', + 'packages/experimental/inspector/tests/client-browser.e2e.ts', ], // Local and record runs stay serial. CI runs workspace-mutating HMR and // dynamic Cordis lifecycle coverage before parallelizing the remaining files.