feat(inspector): define shared protocol foundation

This commit is contained in:
imccyu
2026-08-27 16:15:17 +08:00
parent c6e1914f2d
commit 189fb34797
51 changed files with 3705 additions and 1 deletions
+9
View File
@@ -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"
@@ -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"
}
}
@@ -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))
@@ -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<string, InspectorRecordInput>()
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
}
}
@@ -0,0 +1,4 @@
/** Bridge-facing exports for lossless JSON values and common wire validators. */
export * from '../json.ts'
export * from '../validation.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<string, unknown> {
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
}
@@ -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'>
@@ -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<Port> {
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
}
@@ -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<string, unknown>): 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<string>()
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<string, unknown>,
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
}
@@ -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<string, unknown>): 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)}`)
}
}
@@ -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<ClientRemoteObjectHandle>
/** Property descriptor whose retained values use Client-session handles. */
export type ClientRuntimePropertyDescriptor = RuntimePropertyDescriptor<ClientRemoteObjectHandle>
/** Internal property descriptor whose retained values use Client-session handles. */
export type ClientRuntimeInternalPropertyDescriptor = RuntimeInternalPropertyDescriptor<ClientRemoteObjectHandle>
/** Exception details whose retained value uses a Client-session handle. */
export type ClientRuntimeExceptionDetails = RuntimeExceptionDetails<ClientRemoteObjectHandle>
/** One argument supplied to a function in the Client realm. */
export type ClientCallArgument = RuntimeCallArgument<ClientRemoteObjectHandle>
/** 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<ClientRemoteObjectHandle> {
readonly op: 'get-properties'
}
/** Invoke a function declaration with Client-local receivers and arguments. */
export interface ClientRuntimeCallFunctionCommand extends RuntimeCallFunctionRequest<ClientRemoteObjectHandle> {
readonly op: 'call-function'
}
/** Await one retained Client promise. */
export interface ClientRuntimeAwaitPromiseCommand extends RuntimeAwaitPromiseRequest<ClientRemoteObjectHandle> {
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<ClientRemoteObjectHandle>
/** 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
}
@@ -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<ClientRemoteObjectHandle>
}
/**
* 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<string, unknown>,
): 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<string, unknown>): 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<ClientRemoteObjectHandle> {
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<RuntimeConsoleType>([
'log', 'debug', 'info', 'error', 'warning', 'dir', 'dirxml', 'table', 'trace', 'clear',
'startGroup', 'startGroupCollapsed', 'endGroup', 'assert', 'profile', 'profileEnd', 'count', 'timeEnd',
])
@@ -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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<ClientRuntimeError['code']>([
'invalid-request', 'object-not-found', 'unsupported', 'timeout', 'result-too-large', 'internal-error',
])
@@ -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'
@@ -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<RuntimeRemoteObjectType>([
'object', 'function', 'undefined', 'string', 'number', 'boolean', 'symbol', 'bigint',
])
const REMOTE_SUBTYPES = new Set<RuntimeRemoteObjectSubtype>([
'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`)
}
}
@@ -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<RuntimeScript, 'executionContextId'>
}
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
@@ -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<RuntimeScript, 'executionContextId'>
/** 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
}
@@ -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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<ClientSourceError['code']>([
'invalid-request', 'script-not-found', 'load-failed', 'result-too-large', 'internal-error',
])
@@ -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'
@@ -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 {}
@@ -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<typeof setTimeout>
}
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<InspectorQueryRequestId, PendingQuery>()
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 extends InspectorQuery>(query: Query): Promise<InspectorQueryResultFor<Query>> {
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<InspectorQueryResult>((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<InspectorQueryResultFor<Query>>
}
/**
* 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))
}
@@ -0,0 +1,3 @@
/** Bridge-facing exports for the shared untrusted-value validation primitives. */
export * from '../validation.ts'
@@ -0,0 +1,2 @@
/** Current Inspector wire version. Pre-release peers reject every other version. */
export const INSPECTOR_PROTOCOL_VERSION = 0 as const
@@ -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[]
}
@@ -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<Handle extends string> {
readonly type: RuntimeConsoleType
readonly arguments: readonly RuntimeRemoteObject<Handle>[]
readonly timestamp: number
readonly contextId?: number
readonly stackTrace?: RuntimeStackTrace
}
/** One uncaught exception observed in an inspected realm. */
export interface RuntimeExceptionEvent<Handle extends string> {
readonly timestamp: number
readonly contextId?: number
readonly details: RuntimeExceptionDetails<Handle>
}
/** Console-domain event emitted by a realm backend. */
export type RuntimeConsoleBackendEvent<Handle extends string> =
| { readonly type: 'console-api'; readonly event: RuntimeConsoleEvent<Handle> }
| { readonly type: 'exception'; readonly event: RuntimeExceptionEvent<Handle> }
@@ -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<Handle extends string> {
readonly type: string
readonly object: RuntimeRemoteObject<Handle>
readonly name?: string
readonly startLocation?: RuntimeDebuggerLocation
readonly endLocation?: RuntimeDebuggerLocation
}
/** One paused JavaScript call frame. */
export interface RuntimeDebuggerCallFrame<Handle extends string> {
readonly callFrameId: string
readonly functionName: string
readonly functionLocation?: RuntimeDebuggerLocation
readonly location: RuntimeDebuggerLocation
readonly url: string
readonly scopeChain: readonly RuntimeDebuggerScope<Handle>[]
readonly thisObject: RuntimeRemoteObject<Handle>
readonly returnValue?: RuntimeRemoteObject<Handle>
}
/** 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<Handle extends string> =
| {
readonly type: 'paused'
readonly callFrames: readonly RuntimeDebuggerCallFrame<Handle>[]
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<Handle extends string> = RuntimeCompletion<Handle>
@@ -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<Handle extends string> {
readonly text: string
readonly lineNumber: number
readonly columnNumber: number
readonly url?: string
readonly stackTrace?: RuntimeStackTrace
readonly exception?: RuntimeRemoteObject<Handle>
}
@@ -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'>
@@ -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'
@@ -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<Handle extends string> =
| { 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<Handle extends string> {
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<Handle extends string> {
readonly functionDeclaration: string
readonly receiver?: Handle
readonly arguments?: readonly RuntimeCallArgument<Handle>[]
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<Handle extends string> {
readonly promise: Handle
readonly returnByValue?: boolean
readonly generatePreview?: boolean
}
/** Shared result of evaluation, function calls, and promise awaiting. */
export interface RuntimeCompletion<Handle extends string> {
readonly result: RuntimeRemoteObject<Handle>
readonly exceptionDetails?: RuntimeExceptionDetails<Handle>
}
/** Shared result of property enumeration. */
export interface RuntimeProperties<Handle extends string> {
readonly properties: readonly RuntimePropertyDescriptor<Handle>[]
readonly internalProperties?: readonly RuntimeInternalPropertyDescriptor<Handle>[]
readonly privateProperties?: readonly RuntimePrivatePropertyDescriptor<Handle>[]
readonly exceptionDetails?: RuntimeExceptionDetails<Handle>
}
@@ -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<Handle extends string> {
readonly name: string
readonly value?: RuntimeRemoteObject<Handle>
readonly writable?: boolean
readonly get?: RuntimeRemoteObject<Handle>
readonly set?: RuntimeRemoteObject<Handle>
readonly configurable: boolean
readonly enumerable: boolean
readonly wasThrown?: boolean
readonly isOwn?: boolean
readonly symbol?: RuntimeRemoteObject<Handle>
}
/** One engine-owned property such as `[[Prototype]]`. */
export interface RuntimeInternalPropertyDescriptor<Handle extends string> {
readonly name: string
readonly value?: RuntimeRemoteObject<Handle>
}
/** One engine private property exposed when a backend supports it. */
export interface RuntimePrivatePropertyDescriptor<Handle extends string> {
readonly name: string
readonly value?: RuntimeRemoteObject<Handle>
readonly get?: RuntimeRemoteObject<Handle>
readonly set?: RuntimeRemoteObject<Handle>
}
@@ -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<Record<string, unknown>>
}
/** Explicitly supported or unsupported realm capability. */
export type RealmCapability<Backend> =
| { 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<void>
/** Disable Runtime events and release backend session state. */
disable(): Promise<void>
/**
* Evaluate source in this realm.
* @param request - Engine-independent evaluation request.
* @returns Completion containing a value or JavaScript exception.
*/
evaluate(request: RuntimeEvaluateRequest): Promise<RuntimeCompletion<RuntimeBackendObjectHandle>>
/**
* 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<RuntimeBackendObjectHandle>,
): Promise<RuntimeProperties<RuntimeBackendObjectHandle>>
/**
* 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<RuntimeBackendObjectHandle>,
): Promise<RuntimeCompletion<RuntimeBackendObjectHandle>>
/**
* Await one retained Promise.
* @param request - Promise handle and result options.
* @returns Completion containing the fulfilled value or rejection.
*/
awaitPromise(
request: RuntimeAwaitPromiseRequest<RuntimeBackendObjectHandle>,
): Promise<RuntimeCompletion<RuntimeBackendObjectHandle>>
/** @returns Names visible in the realm's global lexical scope. */
globalLexicalScopeNames(): Promise<readonly string[]>
/**
* Release one backend object reference.
* @param handle - Handle owned by this realm session.
*/
releaseObject(handle: RuntimeBackendObjectHandle): Promise<void>
/**
* Release every backend object retained under one group.
* @param group - DevTools object-group name.
*/
releaseObjectGroup(group: string): Promise<void>
}
/** 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<RuntimeBackendObjectHandle>) => void): () => void
/** Clear backend-owned Console history when supported. */
clear(): Promise<void>
}
/** Realm script catalog independent of CDP ScriptId allocation. */
export interface SourceBackend {
/** @returns Every script currently known to this realm. */
listScripts(): Promise<readonly RuntimeScript[]>
/**
* 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<string>
/**
* 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<string | undefined>
/**
* 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<Readonly<Record<string, unknown>>>
/** Disable debugger events for this connection. */
disable(): Promise<Readonly<Record<string, unknown>>>
/** Pause this realm. */
pause(): Promise<Readonly<Record<string, unknown>>>
/** Resume this realm. */
resume(request: RuntimeDebuggerResumeRequest): Promise<Readonly<Record<string, unknown>>>
/**
* Evaluate an expression in one paused frame.
* @param request - Frame identity, expression, and result options.
* @returns A common Runtime completion.
*/
evaluateOnCallFrame(
request: RuntimeCallFrameEvaluationRequest,
): Promise<RuntimeCompletion<RuntimeBackendObjectHandle>>
/**
* Subscribe to paused, resumed, and breakpoint events.
* @param listener - Connection-local debugger event consumer.
* @returns A disposer removing the consumer.
*/
subscribe(listener: (event: RuntimeDebuggerEvent<RuntimeBackendObjectHandle>) => 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<Record<string, unknown>>): Promise<Readonly<Record<string, unknown>>>
/**
* Subscribe to native protocol notifications.
* @param listener - Notification consumer.
* @returns A disposer removing the consumer.
*/
subscribe(listener: (message: NativeProtocolNotification) => void): () => void
}
@@ -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<Handle extends string> {
readonly handle: Handle
}
/** Realm-neutral value plus optional backend and Cordis identities. */
export interface RuntimeRemoteObject<Handle extends string> {
readonly descriptor: RuntimeRemoteObjectDescriptor
readonly object?: RuntimeBackendObjectReference<Handle>
readonly semanticReference?: InspectorObjectReference
}
@@ -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
}
@@ -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<Role extends string> = Branded<Role>
/**
* 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<Role extends string>(value: string, label: string): InspectorId<Role> {
if (value.length === 0 || value.length > 256) {
throw new Error(`inspector protocol: ${label} must contain 1 to 256 characters`)
}
return value as InspectorId<Role>
}
@@ -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'
@@ -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<object>())
}
/**
* 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<string, unknown> {
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<object>): 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)
}
}
@@ -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<string, unknown> {
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<string, unknown>, 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<Role extends string>(value: unknown, label: string): InspectorId<Role> {
if (typeof value !== 'string') throw new Error(`inspector protocol: ${label} must be a string`)
return inspectorId<Role>(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<Key extends string>(
value: Record<string, unknown>,
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<Key extends string>(
value: Record<string, unknown>,
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<Key extends string>(
value: Record<string, unknown>,
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 }
}
@@ -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')
})
})
@@ -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')
})
})
@@ -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"
}
]
}
@@ -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"
}
]
}
@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.host.json"
},
{
"path": "./tsconfig.client.json"
}
]
}
@@ -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] },
)
+43
View File
@@ -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':
+3
View File
@@ -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",
+1
View File
@@ -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" },
+1
View File
@@ -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" },
+6
View File
@@ -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
+4 -1
View File
@@ -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.
+1
View File
@@ -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.