瀏覽代碼

feat(inspector): define shared protocol foundation

imccyu 1 月之前
父節點
當前提交
189fb34797
共有 51 個文件被更改,包括 3705 次插入 和 1 次删除
  1. 9 0
      knip.json
  2. 67 0
      packages/experimental/inspector/package.json
  3. 22 0
      packages/experimental/inspector/src/invariant.ts
  4. 157 0
      packages/experimental/inspector/src/shared/bridge/buffer.ts
  5. 4 0
      packages/experimental/inspector/src/shared/bridge/codec.ts
  6. 153 0
      packages/experimental/inspector/src/shared/bridge/control-codec.ts
  7. 27 0
      packages/experimental/inspector/src/shared/bridge/ids.ts
  8. 72 0
      packages/experimental/inspector/src/shared/bridge/messages/control.ts
  9. 363 0
      packages/experimental/inspector/src/shared/bridge/messages/observation.ts
  10. 134 0
      packages/experimental/inspector/src/shared/bridge/messages/runtime/command-codec.ts
  11. 101 0
      packages/experimental/inspector/src/shared/bridge/messages/runtime/commands.ts
  12. 147 0
      packages/experimental/inspector/src/shared/bridge/messages/runtime/console-frames.ts
  13. 147 0
      packages/experimental/inspector/src/shared/bridge/messages/runtime/frames.ts
  14. 5 0
      packages/experimental/inspector/src/shared/bridge/messages/runtime/index.ts
  15. 334 0
      packages/experimental/inspector/src/shared/bridge/messages/runtime/value-codec.ts
  16. 127 0
      packages/experimental/inspector/src/shared/bridge/messages/sources/codec.ts
  17. 47 0
      packages/experimental/inspector/src/shared/bridge/messages/sources/commands.ts
  18. 143 0
      packages/experimental/inspector/src/shared/bridge/messages/sources/frames.ts
  19. 5 0
      packages/experimental/inspector/src/shared/bridge/messages/sources/index.ts
  20. 24 0
      packages/experimental/inspector/src/shared/bridge/publisher.ts
  21. 182 0
      packages/experimental/inspector/src/shared/bridge/rpc.ts
  22. 3 0
      packages/experimental/inspector/src/shared/bridge/validation.ts
  23. 2 0
      packages/experimental/inspector/src/shared/bridge/version.ts
  24. 28 0
      packages/experimental/inspector/src/shared/cdp/capabilities.ts
  25. 46 0
      packages/experimental/inspector/src/shared/cdp/console.ts
  26. 78 0
      packages/experimental/inspector/src/shared/cdp/debugger.ts
  27. 30 0
      packages/experimental/inspector/src/shared/cdp/errors.ts
  28. 12 0
      packages/experimental/inspector/src/shared/cdp/ids.ts
  29. 11 0
      packages/experimental/inspector/src/shared/cdp/index.ts
  30. 80 0
      packages/experimental/inspector/src/shared/cdp/operations.ts
  31. 31 0
      packages/experimental/inspector/src/shared/cdp/property.ts
  32. 157 0
      packages/experimental/inspector/src/shared/cdp/realm.ts
  33. 78 0
      packages/experimental/inspector/src/shared/cdp/remote-object.ts
  34. 19 0
      packages/experimental/inspector/src/shared/cdp/sources.ts
  35. 19 0
      packages/experimental/inspector/src/shared/identity.ts
  36. 18 0
      packages/experimental/inspector/src/shared/index.ts
  37. 79 0
      packages/experimental/inspector/src/shared/json.ts
  38. 93 0
      packages/experimental/inspector/src/shared/validation.ts
  39. 265 0
      packages/experimental/inspector/tests/protocol.host.spec.ts
  40. 43 0
      packages/experimental/inspector/tests/source-buffer.host.spec.ts
  41. 96 0
      packages/experimental/inspector/tsconfig.client.json
  42. 155 0
      packages/experimental/inspector/tsconfig.host.json
  43. 11 0
      packages/experimental/inspector/tsconfig.json
  44. 22 0
      packages/experimental/inspector/tsdown.config.ts
  45. 43 0
      pnpm-lock.yaml
  46. 3 0
      tsconfig.base.json
  47. 1 0
      tsconfig.client.json
  48. 1 0
      tsconfig.host.json
  49. 6 0
      vitest.config.ts
  50. 4 1
      vitest.e2e.config.ts
  51. 1 0
      vitest.web.config.ts

+ 9 - 0
knip.json

@@ -45,6 +45,15 @@
         "@deepseek-ai/dsh-client-ui-directory-picker-native"
       ]
     },
+    "packages/experimental/inspector": {
+      "entry": [
+        "tests/**/*.e2e.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
+    },
     "packages/extensions/cordis-host-runner": {
       "entry": [
         "tests/**/*.spec.ts"

+ 67 - 0
packages/experimental/inspector/package.json

@@ -0,0 +1,67 @@
+{
+  "name": "@deepseek-ai/dsh-experimental-inspector",
+  "description": "Experimental cross-realm CDP hub for Host debugging and Client Runtime inspection",
+  "version": "0.1.1-rc.2",
+  "private": true,
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+    "directory": "packages/experimental/inspector"
+  },
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./client": {
+      "types": "./lib/types/client/index.d.ts",
+      "default": "./lib/client.js"
+    },
+    "./invariant": {
+      "types": "./lib/types/invariant.d.ts",
+      "default": "./lib/invariant.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "dsh": {
+    "client": {
+      "inject": [],
+      "platform": "web",
+      "immediately": true
+    }
+  },
+  "files": [
+    "lib/index.js",
+    "lib/invariant.js",
+    "lib/client.js",
+    "lib/types/**/*.d.ts"
+  ],
+  "license": "MIT",
+  "dependencies": {
+    "@deepseek-ai/dsh-brand": "workspace:^",
+    "@deepseek-ai/dsh-util-crypto": "workspace:^",
+    "@deepseek-ai/schemastery": "workspace:^",
+    "ws": "^8.21.0"
+  },
+  "peerDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/cordis-plugin-loader": "workspace:^",
+    "@deepseek-ai/dsh-client-modules": "workspace:^",
+    "@deepseek-ai/dsh-host-webserver": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^"
+  },
+  "devDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/cordis-plugin-include": "workspace:^",
+    "@deepseek-ai/cordis-plugin-loader": "workspace:^",
+    "@deepseek-ai/dsh-host-webserver": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@types/ws": "^8.18.1",
+    "playwright": "^1.49.0",
+    "tsx": "^4.19.2"
+  }
+}

+ 22 - 0
packages/experimental/inspector/src/invariant.ts

@@ -0,0 +1,22 @@
+/** Package-owned invariant companion for the experimental Inspector. */
+
+import type { Context } from '@deepseek-ai/cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-experimental-inspector'
+
+/** Cordis companion plugin name. */
+export const name = 'experimental-inspector-invariant'
+
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/**
+ * No runtime invariant: wire parsing, generations, Worker lifecycle, and CDP
+ * sessions reject invalid relationships in their owning operations.
+ */
+const install: InvariantInstaller = () => {}
+
+/** Register this package's invariant companion. */
+export const apply = (ctx: Context): Promise<() => void> =>
+  Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))

+ 157 - 0
packages/experimental/inspector/src/shared/bridge/buffer.ts

@@ -0,0 +1,157 @@
+/** Realm-neutral bounded buffering for Host and Client observation sources. */
+
+import type { InspectorSourceGeneration, InspectorSourceId } from './ids.ts'
+import { isJsonValue, jsonByteLength, type InspectorJsonValue } from '../json.ts'
+import type { InspectorRecordInput, SourceAppendFrame, SourceReplaceFrame } from './messages/observation.ts'
+import { INSPECTOR_PROTOCOL_VERSION } from './version.ts'
+
+const SOURCE_FRAME_OVERHEAD_BYTES = 4_096
+
+/** Limits and declared topics shared by both source transports. */
+export interface InspectorSourceBufferOptions {
+  readonly topics: readonly string[]
+  readonly maxQueuedRecords: number
+  readonly maxQueuedBytes: number
+  readonly maxRecordsPerFrame: number
+  readonly maxFrameBytes: number
+}
+
+interface QueuedRecord {
+  sequence: number
+  readonly bytes: number
+  readonly record: InspectorRecordInput
+}
+
+/**
+ * Owns retained state, queued events, and source-local sequencing independently
+ * of whether frames travel over MessagePort or WebSocket.
+ */
+export class InspectorSourceBuffer {
+  private readonly queue: QueuedRecord[] = []
+  private readonly state = new Map<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
+  }
+}

+ 4 - 0
packages/experimental/inspector/src/shared/bridge/codec.ts

@@ -0,0 +1,4 @@
+/** Bridge-facing exports for lossless JSON values and common wire validators. */
+
+export * from '../json.ts'
+export * from '../validation.ts'

+ 153 - 0
packages/experimental/inspector/src/shared/bridge/control-codec.ts

@@ -0,0 +1,153 @@
+/** Exact decoders for Host, Worker, and injected Client lifecycle values. */
+
+import type {
+  InspectorClientBootstrap,
+  InspectorHostControl,
+  InspectorWorkerConfig,
+  InspectorWorkerControl,
+} from './messages/control.ts'
+import { isPlainObject } from '../json.ts'
+import { exactKeys, exactObject } from '../validation.ts'
+
+/**
+ * Decode the structured-cloned Worker configuration.
+ * @param value - Untrusted workerData config value.
+ * @returns The validated Worker configuration.
+ */
+export function parseInspectorWorkerConfig(value: unknown): InspectorWorkerConfig {
+  const record = exactObject(value, [
+    'host', 'startPort', 'targetId', 'clientToken', 'clientOrigins', 'maxSourceFrameBytes',
+    'maxSourceRecordsPerFrame', 'maxRetainedRequests', 'maxJournalBytes', 'clientRuntimeTimeoutMs', 'maxCordisNodes',
+    'maxDisconnectedCordisTrees', 'maxClientSourceBytes',
+  ], 'Worker config')
+  if (record.host !== '127.0.0.1') throw new Error('inspector protocol: Worker host must be 127.0.0.1')
+  if (typeof record.targetId !== 'string' || record.targetId.length === 0) {
+    throw new Error('inspector protocol: Worker targetId must be a non-empty string')
+  }
+  if (typeof record.clientToken !== 'string' || record.clientToken.length === 0) {
+    throw new Error('inspector protocol: Worker clientToken must be a non-empty string')
+  }
+  if (!Array.isArray(record.clientOrigins) || !record.clientOrigins.every(origin => typeof origin === 'string')) {
+    throw new Error('inspector protocol: Worker clientOrigins must be strings')
+  }
+  const startPort = natural(record.startPort, 'startPort', true)
+  if (startPort > 65_535) throw new Error('inspector protocol: Worker startPort must not exceed 65535')
+  return {
+    host: record.host,
+    startPort,
+    targetId: record.targetId,
+    clientToken: record.clientToken,
+    clientOrigins: record.clientOrigins,
+    maxSourceFrameBytes: natural(record.maxSourceFrameBytes, 'maxSourceFrameBytes'),
+    maxSourceRecordsPerFrame: natural(record.maxSourceRecordsPerFrame, 'maxSourceRecordsPerFrame'),
+    maxRetainedRequests: natural(record.maxRetainedRequests, 'maxRetainedRequests'),
+    maxJournalBytes: natural(record.maxJournalBytes, 'maxJournalBytes'),
+    clientRuntimeTimeoutMs: natural(record.clientRuntimeTimeoutMs, 'clientRuntimeTimeoutMs'),
+    maxClientSourceBytes: natural(record.maxClientSourceBytes, 'maxClientSourceBytes'),
+    maxCordisNodes: natural(record.maxCordisNodes, 'maxCordisNodes'),
+    maxDisconnectedCordisTrees: natural(record.maxDisconnectedCordisTrees, 'maxDisconnectedCordisTrees', true),
+  }
+}
+
+/**
+ * Decode one Host-to-Worker lifecycle command.
+ * @param value - Untrusted control message.
+ * @returns The validated Host command.
+ */
+export function parseInspectorHostControl(value: unknown): InspectorHostControl {
+  const record = exactObject(value, ['type'], 'Host control message')
+  if (record.type !== 'shutdown') throw new Error('inspector protocol: unknown Host control message')
+  return { type: 'shutdown' }
+}
+
+/**
+ * Decode one Worker-to-Host lifecycle event.
+ * @param value - Untrusted control message.
+ * @returns The validated Worker event.
+ */
+export function parseInspectorWorkerControl(value: unknown): InspectorWorkerControl {
+  const record = exactObjectByType(value, 'Worker control message')
+  switch (record.type) {
+    case 'ready':
+      exactKeys(record, ['type', 'host', 'port', 'targetId'], 'Worker ready message')
+      if (typeof record.host !== 'string' || typeof record.targetId !== 'string') {
+        throw new Error('inspector protocol: invalid Worker ready identity')
+      }
+      return {
+        type: 'ready',
+        host: record.host,
+        port: natural(record.port, 'port', true),
+        targetId: record.targetId,
+      }
+    case 'failure':
+      exactKeys(record, ['type', 'message'], 'Worker failure message')
+      if (typeof record.message !== 'string') throw new Error('inspector protocol: invalid Worker failure')
+      return { type: 'failure', message: record.message }
+    case 'stopped':
+      exactKeys(record, ['type'], 'Worker stopped message')
+      return { type: 'stopped' }
+    default:
+      throw new Error('inspector protocol: unknown Worker control message')
+  }
+}
+
+/**
+ * Decode bootstrap data injected into the browser global.
+ * @param value - Untrusted injected value.
+ * @returns The validated Client bootstrap.
+ */
+export function parseInspectorClientBootstrap(value: unknown): InspectorClientBootstrap {
+  const record = exactObject(value, [
+    'endpoint', 'protocol', 'maxQueuedRecords', 'maxQueuedBytes', 'maxRecordsPerFrame', 'maxFrameBytes',
+    'reconnectBaseMs', 'reconnectMaxMs', 'queryTimeoutMs', 'maxRuntimeObjectsPerSession',
+    'maxRuntimePropertiesPerResult', 'maxCordisNodes', 'maxClientSourceBytes',
+  ], 'Client bootstrap')
+  if (typeof record.endpoint !== 'string' || typeof record.protocol !== 'string') {
+    throw new Error('inspector protocol: Client bootstrap endpoint and protocol must be strings')
+  }
+  let endpoint: URL
+  try {
+    endpoint = new URL(record.endpoint)
+  } catch {
+    throw new Error('inspector protocol: Client bootstrap endpoint must be an absolute URL')
+  }
+  if (endpoint.protocol !== 'ws:' || endpoint.hostname !== '127.0.0.1') {
+    throw new Error('inspector protocol: Client bootstrap endpoint must use ws on 127.0.0.1')
+  }
+  if (record.protocol.length === 0 || record.protocol.length > 256) {
+    throw new Error('inspector protocol: Client bootstrap protocol must contain 1 to 256 characters')
+  }
+  const bootstrap: InspectorClientBootstrap = {
+    endpoint: record.endpoint,
+    protocol: record.protocol,
+    maxQueuedRecords: natural(record.maxQueuedRecords, 'maxQueuedRecords'),
+    maxQueuedBytes: natural(record.maxQueuedBytes, 'maxQueuedBytes'),
+    maxRecordsPerFrame: natural(record.maxRecordsPerFrame, 'maxRecordsPerFrame'),
+    maxFrameBytes: natural(record.maxFrameBytes, 'maxFrameBytes'),
+    reconnectBaseMs: natural(record.reconnectBaseMs, 'reconnectBaseMs'),
+    reconnectMaxMs: natural(record.reconnectMaxMs, 'reconnectMaxMs'),
+    queryTimeoutMs: natural(record.queryTimeoutMs, 'queryTimeoutMs'),
+    maxRuntimeObjectsPerSession: natural(record.maxRuntimeObjectsPerSession, 'maxRuntimeObjectsPerSession'),
+    maxRuntimePropertiesPerResult: natural(record.maxRuntimePropertiesPerResult, 'maxRuntimePropertiesPerResult'),
+    maxClientSourceBytes: natural(record.maxClientSourceBytes, 'maxClientSourceBytes'),
+    maxCordisNodes: natural(record.maxCordisNodes, 'maxCordisNodes'),
+  }
+  if (bootstrap.reconnectMaxMs < bootstrap.reconnectBaseMs) {
+    throw new Error('inspector protocol: reconnectMaxMs must be at least reconnectBaseMs')
+  }
+  return bootstrap
+}
+
+function exactObjectByType(value: unknown, label: string): Record<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
+}

+ 27 - 0
packages/experimental/inspector/src/shared/bridge/ids.ts

@@ -0,0 +1,27 @@
+/** Opaque identifiers owned by the cross-realm Inspector bridge. */
+
+import type { InspectorId } from '../identity.ts'
+
+export { inspectorId } from '../identity.ts'
+export type { InspectorId } from '../identity.ts'
+
+/** Stable identity of one logical observation source. */
+export type InspectorSourceId = InspectorId<'InspectorSourceId'>
+
+/** Identity of one source connection generation. */
+export type InspectorSourceGeneration = InspectorId<'InspectorSourceGeneration'>
+
+/** Identity of one DevTools connection's Client Runtime state. */
+export type ClientRuntimeSessionId = InspectorId<'ClientRuntimeSessionId'>
+
+/** Identity of one in-flight Worker-to-Client Runtime operation. */
+export type ClientRuntimeRequestId = InspectorId<'ClientRuntimeRequestId'>
+
+/** Identity of one DevTools connection's Client source catalog session. */
+export type ClientSourceSessionId = InspectorId<'ClientSourceSessionId'>
+
+/** Identity of one in-flight Worker-to-Client source operation. */
+export type ClientSourceRequestId = InspectorId<'ClientSourceRequestId'>
+
+/** Opaque reference to an object retained inside one Client Runtime session. */
+export type ClientRemoteObjectHandle = InspectorId<'ClientRemoteObjectHandle'>

+ 72 - 0
packages/experimental/inspector/src/shared/bridge/messages/control.ts

@@ -0,0 +1,72 @@
+/** Host-to-Worker lifecycle messages and Worker readiness results. */
+
+/** Fully resolved Worker configuration. */
+export interface InspectorWorkerConfig {
+  readonly host: '127.0.0.1'
+  /** First port to bind; zero delegates selection to the operating system. */
+  readonly startPort: number
+  readonly targetId: string
+  readonly clientToken: string
+  readonly clientOrigins: readonly string[]
+  readonly maxSourceFrameBytes: number
+  readonly maxSourceRecordsPerFrame: number
+  readonly maxRetainedRequests: number
+  readonly maxJournalBytes: number
+  readonly clientRuntimeTimeoutMs: number
+  readonly maxClientSourceBytes: number
+  readonly maxCordisNodes: number
+  readonly maxDisconnectedCordisTrees: number
+}
+
+/** Structured-clone payload used to start the Inspector Worker. */
+export interface InspectorWorkerBoot<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
+}

+ 363 - 0
packages/experimental/inspector/src/shared/bridge/messages/observation.ts

@@ -0,0 +1,363 @@
+/** Versioned source lifecycle, observation, and extension frames shared by both carriers. */
+
+import { inspectorId, type InspectorSourceGeneration, type InspectorSourceId } from '../ids.ts'
+import { isJsonValue, isPlainObject, type InspectorJsonValue } from '../../json.ts'
+import { exactKeys } from '../../validation.ts'
+import { INSPECTOR_PROTOCOL_VERSION } from '../version.ts'
+import {
+  parseClientConsoleCapability,
+  parseClientConsoleControlFrame,
+  parseClientConsoleEventFrame,
+  parseClientRuntimeCapability,
+  parseClientRuntimeRequestFrame,
+  parseClientRuntimeResponseFrame,
+  parseClientRuntimeSessionClosedFrame,
+  type ClientConsoleCapability,
+  type ClientConsoleDisableFrame,
+  type ClientConsoleEnableFrame,
+  type ClientConsoleEventFrame,
+  type ClientRuntimeCapability,
+  type ClientRuntimeRequestFrame,
+  type ClientRuntimeResponseFrame,
+  type ClientRuntimeSessionClosedFrame,
+} from './runtime/index.ts'
+import {
+  parseClientSourceRequestFrame,
+  parseClientSourceResponseFrame,
+  parseClientSourceSessionClosedFrame,
+  parseClientSourcesCapability,
+  type ClientSourceRequestFrame,
+  type ClientSourceResponseFrame,
+  type ClientSourceSessionClosedFrame,
+  type ClientSourcesCapability,
+} from './sources/index.ts'
+
+export { INSPECTOR_PROTOCOL_VERSION } from '../version.ts'
+
+/** Realm producing observations. */
+export type InspectorSourceKind = 'host' | 'client'
+
+/** Optional protocols implemented by one source generation. */
+export type InspectorSourceCapability = ClientRuntimeCapability | ClientConsoleCapability | ClientSourcesCapability
+
+/** One logical source and connection generation. */
+export interface InspectorSourceDescriptor {
+  /** Producer identity retained across transport reconnects. */
+  readonly sourceId: InspectorSourceId
+  /** One transport admission, replaced on every reconnect. */
+  readonly generation: InspectorSourceGeneration
+  readonly kind: InspectorSourceKind
+  readonly label: string
+  readonly timeOriginMs: number
+  readonly capabilities: readonly InspectorSourceCapability[]
+}
+
+/** One domain-owned observation before its sequence is assigned. */
+export interface InspectorRecordInput {
+  readonly monotonicMs: number
+  readonly topic: string
+  readonly payload: InspectorJsonValue
+}
+
+/** Initial source handshake. */
+export interface SourceOpenFrame {
+  readonly v: typeof INSPECTOR_PROTOCOL_VERSION
+  readonly t: 'source/open'
+  readonly source: InspectorSourceDescriptor
+  readonly topics: readonly string[]
+}
+
+/** Replace one source's current state after opening or resynchronization. */
+export interface SourceReplaceFrame {
+  readonly v: typeof INSPECTOR_PROTOCOL_VERSION
+  readonly t: 'source/replace'
+  readonly sourceId: InspectorSourceId
+  readonly generation: InspectorSourceGeneration
+  readonly nextSequence: number
+  readonly records: readonly InspectorRecordInput[]
+}
+
+/** Append one contiguous observation batch. */
+export interface SourceAppendFrame {
+  readonly v: typeof INSPECTOR_PROTOCOL_VERSION
+  readonly t: 'source/append'
+  readonly sourceId: InspectorSourceId
+  readonly generation: InspectorSourceGeneration
+  readonly firstSequence: number
+  readonly droppedBefore: number
+  readonly records: readonly InspectorRecordInput[]
+}
+
+/** Clean source closure. */
+export interface SourceCloseFrame {
+  readonly v: typeof INSPECTOR_PROTOCOL_VERSION
+  readonly t: 'source/close'
+  readonly sourceId: InspectorSourceId
+  readonly generation: InspectorSourceGeneration
+}
+
+/** Every source-to-Worker frame. */
+export type SourceToWorkerFrame =
+  | SourceOpenFrame
+  | SourceReplaceFrame
+  | SourceAppendFrame
+  | SourceCloseFrame
+  | ClientConsoleEventFrame
+  | ClientRuntimeResponseFrame
+  | ClientSourceResponseFrame
+
+/** Worker acceptance of one source generation. */
+export interface SourceAcceptedFrame {
+  readonly v: typeof INSPECTOR_PROTOCOL_VERSION
+  readonly t: 'source/accepted'
+  readonly sourceId: InspectorSourceId
+  readonly generation: InspectorSourceGeneration
+}
+
+/** Worker request for a complete source-state replacement. */
+export interface SourceResnapshotFrame {
+  readonly v: typeof INSPECTOR_PROTOCOL_VERSION
+  readonly t: 'source/resnapshot'
+  readonly sourceId: InspectorSourceId
+  readonly generation: InspectorSourceGeneration
+  readonly expectedSequence: number
+  readonly reason: string
+}
+
+/** Rejection of one malformed or incompatible source connection. */
+export interface SourceRejectedFrame {
+  readonly v: typeof INSPECTOR_PROTOCOL_VERSION
+  readonly t: 'source/rejected'
+  readonly code: 'invalid-frame' | 'version-mismatch' | 'unauthorized'
+  readonly message: string
+}
+
+/** Every Worker-to-source control frame. */
+export type WorkerToSourceFrame =
+  | SourceAcceptedFrame
+  | SourceResnapshotFrame
+  | SourceRejectedFrame
+  | ClientConsoleEnableFrame
+  | ClientConsoleDisableFrame
+  | ClientRuntimeRequestFrame
+  | ClientRuntimeSessionClosedFrame
+  | ClientSourceRequestFrame
+  | ClientSourceSessionClosedFrame
+
+/**
+ * Parse and rebuild one Worker control frame received by a source.
+ * @param value - Untrusted decoded wire value.
+ * @returns The validated Worker-to-source frame.
+ */
+export function parseWorkerSourceFrame(value: unknown): WorkerToSourceFrame {
+  if (!isJsonValue(value)
+    || !isPlainObject(value)
+    || value.v !== INSPECTOR_PROTOCOL_VERSION
+    || typeof value.t !== 'string') {
+    throw new Error('inspector protocol: invalid Worker source frame')
+  }
+  if (value.t === 'source/rejected') {
+    exactKeys(value, ['v', 't', 'code', 'message'], 'source/rejected frame')
+    if ((value.code !== 'invalid-frame' && value.code !== 'version-mismatch' && value.code !== 'unauthorized')
+      || typeof value.message !== 'string') {
+      throw new Error('inspector protocol: invalid source/rejected frame')
+    }
+    return { v: INSPECTOR_PROTOCOL_VERSION, t: 'source/rejected', code: value.code, message: value.message }
+  }
+  if (value.t === 'client-runtime/request') return parseClientRuntimeRequestFrame(value)
+  if (value.t === 'client-runtime/session-closed') return parseClientRuntimeSessionClosedFrame(value)
+  if (value.t === 'client-sources/request') return parseClientSourceRequestFrame(value)
+  if (value.t === 'client-sources/session-closed') return parseClientSourceSessionClosedFrame(value)
+  if (value.t === 'client-console/enable' || value.t === 'client-console/disable') {
+    return parseClientConsoleControlFrame(value)
+  }
+  const common = {
+    v: INSPECTOR_PROTOCOL_VERSION,
+    sourceId: sourceId(value.sourceId),
+    generation: generation(value.generation),
+  } as const
+  if (value.t === 'source/accepted') {
+    exactKeys(value, ['v', 't', 'sourceId', 'generation'], 'source/accepted frame')
+    return { ...common, t: 'source/accepted' }
+  }
+  if (value.t === 'source/resnapshot'
+    && typeof value.reason === 'string') {
+    exactKeys(value, ['v', 't', 'sourceId', 'generation', 'expectedSequence', 'reason'], 'source/resnapshot frame')
+    return {
+      ...common,
+      t: 'source/resnapshot',
+      expectedSequence: natural(value.expectedSequence, 'expectedSequence'),
+      reason: value.reason,
+    }
+  }
+  throw new Error(`inspector protocol: unknown Worker source frame ${JSON.stringify(value.t)}`)
+}
+
+/**
+ * Parse and rebuild one source frame received at a process or network boundary.
+ * @param value - Untrusted decoded wire value.
+ * @param maxRecords - Maximum records admitted in one frame.
+ * @returns The validated source-to-Worker frame.
+ */
+export function parseSourceFrame(value: unknown, maxRecords: number): SourceToWorkerFrame {
+  if (!isJsonValue(value) || !isPlainObject(value)) {
+    throw new Error('inspector protocol: source frame must be a lossless JSON object')
+  }
+  if (value.v !== INSPECTOR_PROTOCOL_VERSION) {
+    throw new Error(`inspector protocol: unsupported version ${JSON.stringify(value.v)}`)
+  }
+  switch (value.t) {
+    case 'source/open':
+      return parseOpen(value)
+    case 'source/replace':
+      return parseRecordsFrame(value, maxRecords, true)
+    case 'source/append':
+      return parseRecordsFrame(value, maxRecords, false)
+    case 'source/close':
+      exactKeys(value, ['v', 't', 'sourceId', 'generation'], 'source/close frame')
+      return {
+        v: INSPECTOR_PROTOCOL_VERSION,
+        t: 'source/close',
+        sourceId: sourceId(value.sourceId),
+        generation: generation(value.generation),
+      }
+    case 'client-runtime/response':
+      return parseClientRuntimeResponseFrame(value)
+    case 'client-console/event':
+      return parseClientConsoleEventFrame(value)
+    case 'client-sources/response':
+      return parseClientSourceResponseFrame(value)
+    default:
+      throw new Error(`inspector protocol: unknown source frame ${JSON.stringify(value.t)}`)
+  }
+}
+
+function parseOpen(value: Record<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
+}

+ 134 - 0
packages/experimental/inspector/src/shared/bridge/messages/runtime/command-codec.ts

@@ -0,0 +1,134 @@
+/** Exact wire decoder for Client Runtime commands. */
+
+import { isJsonValue, isPlainObject } from '../../../json.ts'
+import { exactKeys, optionalBoolean, optionalNonNegativeNumber, optionalString, wireId } from '../../../validation.ts'
+import type { ClientCallArgument, ClientRuntimeCallFunctionCommand, ClientRuntimeCommand } from './commands.ts'
+
+/**
+ * Parse and rebuild one Runtime command before it enters the Client realm.
+ * @param value - Untrusted command value.
+ * @returns The validated command union member.
+ */
+export function parseClientRuntimeCommand(value: unknown): ClientRuntimeCommand {
+  if (!isPlainObject(value) || typeof value.op !== 'string') {
+    throw new Error('inspector protocol: Client Runtime command must have an op')
+  }
+  switch (value.op) {
+    case 'evaluate': {
+      exactKeys(value, [
+        'op', 'expression', 'objectGroup', 'includeCommandLineAPI', 'silent', 'returnByValue',
+        'generatePreview', 'userGesture', 'awaitPromise', 'disableBreaks', 'replMode',
+        'allowUnsafeEvalBlockedByCSP', 'timeoutMs',
+      ], 'evaluate command')
+      if (typeof value.expression !== 'string') throw new Error('inspector protocol: evaluate expression must be a string')
+      return {
+        op: 'evaluate',
+        expression: value.expression,
+        ...optionalString(value, 'objectGroup'),
+        ...optionalBoolean(value, 'includeCommandLineAPI'),
+        ...optionalBoolean(value, 'silent'),
+        ...optionalBoolean(value, 'returnByValue'),
+        ...optionalBoolean(value, 'generatePreview'),
+        ...optionalBoolean(value, 'userGesture'),
+        ...optionalBoolean(value, 'awaitPromise'),
+        ...optionalBoolean(value, 'disableBreaks'),
+        ...optionalBoolean(value, 'replMode'),
+        ...optionalBoolean(value, 'allowUnsafeEvalBlockedByCSP'),
+        ...optionalNonNegativeNumber(value, 'timeoutMs'),
+      }
+    }
+    case 'get-properties':
+      exactKeys(value, [
+        'op', 'handle', 'ownProperties', 'accessorPropertiesOnly', 'generatePreview', 'nonIndexedPropertiesOnly',
+      ], 'get-properties command')
+      return {
+        op: 'get-properties',
+        handle: wireId<'ClientRemoteObjectHandle'>(value.handle, 'handle'),
+        ...optionalBoolean(value, 'ownProperties'),
+        ...optionalBoolean(value, 'accessorPropertiesOnly'),
+        ...optionalBoolean(value, 'generatePreview'),
+        ...optionalBoolean(value, 'nonIndexedPropertiesOnly'),
+      }
+    case 'call-function':
+      return parseCallFunction(value)
+    case 'await-promise':
+      exactKeys(value, ['op', 'promise', 'returnByValue', 'generatePreview'], 'await-promise command')
+      return {
+        op: 'await-promise',
+        promise: wireId<'ClientRemoteObjectHandle'>(value.promise, 'promise'),
+        ...optionalBoolean(value, 'returnByValue'),
+        ...optionalBoolean(value, 'generatePreview'),
+      }
+    case 'release-object':
+      exactKeys(value, ['op', 'handle'], 'release-object command')
+      return {
+        op: 'release-object',
+        handle: wireId<'ClientRemoteObjectHandle'>(value.handle, 'handle'),
+      }
+    case 'release-object-group':
+      exactKeys(value, ['op', 'objectGroup'], 'release-object-group command')
+      if (typeof value.objectGroup !== 'string') throw new Error('inspector protocol: objectGroup must be a string')
+      return { op: 'release-object-group', objectGroup: value.objectGroup }
+    case 'global-lexical-scope-names':
+      exactKeys(value, ['op'], 'global-lexical-scope-names command')
+      return { op: 'global-lexical-scope-names' }
+    default:
+      throw new Error(`inspector protocol: unknown Client Runtime command ${JSON.stringify(value.op)}`)
+  }
+}
+
+function parseCallFunction(value: Record<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)}`)
+  }
+}

+ 101 - 0
packages/experimental/inspector/src/shared/bridge/messages/runtime/commands.ts

@@ -0,0 +1,101 @@
+/** Closed command/result protocol for Runtime operations executed by a Client. */
+
+import type { ClientRemoteObjectHandle } from '../../ids.ts'
+import type {
+  RuntimeExceptionDetails,
+  RuntimeInternalPropertyDescriptor,
+  RuntimeCallArgument,
+  RuntimeAwaitPromiseRequest,
+  RuntimeCallFunctionRequest,
+  RuntimeCompletion,
+  RuntimeEvaluateRequest,
+  RuntimeGetPropertiesRequest,
+  RuntimePropertyDescriptor,
+  RuntimeRemoteObject,
+} from '../../../cdp/index.ts'
+
+/** Runtime object serialized with one Client-session handle when retained. */
+export type ClientRuntimeRemoteObject = RuntimeRemoteObject<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
+}

+ 147 - 0
packages/experimental/inspector/src/shared/bridge/messages/runtime/console-frames.ts

@@ -0,0 +1,147 @@
+/** Typed transport for Client Console sessions and events. */
+
+import type { ClientRemoteObjectHandle, ClientRuntimeSessionId, InspectorSourceGeneration, InspectorSourceId } from '../../ids.ts'
+import { isPlainObject } from '../../../json.ts'
+import type { RuntimeConsoleBackendEvent, RuntimeConsoleType } from '../../../cdp/index.ts'
+import { exactKeys, exactObject, wireId } from '../../../validation.ts'
+import { INSPECTOR_PROTOCOL_VERSION } from '../../version.ts'
+import {
+  parseClientRuntimeExceptionDetails,
+  parseClientRuntimeRemoteObject,
+  parseClientRuntimeStackTrace,
+} from './value-codec.ts'
+
+/** Source capability that permits Client Console event forwarding. */
+export interface ClientConsoleCapability {
+  readonly type: 'client-console'
+}
+
+/** Worker request to start Console observation for one DevTools session. */
+export interface ClientConsoleEnableFrame {
+  readonly v: typeof INSPECTOR_PROTOCOL_VERSION
+  readonly t: 'client-console/enable'
+  readonly sourceId: InspectorSourceId
+  readonly generation: InspectorSourceGeneration
+  readonly sessionId: ClientRuntimeSessionId
+}
+
+/** Worker request to stop Console observation for one DevTools session. */
+export interface ClientConsoleDisableFrame {
+  readonly v: typeof INSPECTOR_PROTOCOL_VERSION
+  readonly t: 'client-console/disable'
+  readonly sourceId: InspectorSourceId
+  readonly generation: InspectorSourceGeneration
+  readonly sessionId: ClientRuntimeSessionId
+}
+
+/** Client Console event carrying objects retained for one DevTools session. */
+export interface ClientConsoleEventFrame {
+  readonly v: typeof INSPECTOR_PROTOCOL_VERSION
+  readonly t: 'client-console/event'
+  readonly sourceId: InspectorSourceId
+  readonly generation: InspectorSourceGeneration
+  readonly sessionId: ClientRuntimeSessionId
+  readonly event: RuntimeConsoleBackendEvent<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',
+])

+ 147 - 0
packages/experimental/inspector/src/shared/bridge/messages/runtime/frames.ts

@@ -0,0 +1,147 @@
+/** Versioned envelopes for Worker-to-Client Runtime operations. */
+
+import type {
+  ClientRuntimeRequestId,
+  ClientRuntimeSessionId,
+  InspectorSourceGeneration,
+  InspectorSourceId,
+} from '../../ids.ts'
+import { isPlainObject } from '../../../json.ts'
+import { exactKeys, exactObject, wireId } from '../../../validation.ts'
+import { INSPECTOR_PROTOCOL_VERSION } from '../../version.ts'
+import { parseClientRuntimeCommand } from './command-codec.ts'
+import { parseClientRuntimeResult } from './value-codec.ts'
+import type { ClientRuntimeCommand, ClientRuntimeError, ClientRuntimeResult } from './commands.ts'
+
+/** Source capability that permits synthetic Runtime execution contexts. */
+export interface ClientRuntimeCapability {
+  readonly type: 'client-runtime'
+  readonly origin: string
+}
+
+/** Worker request for one operation in a specific source generation and DevTools session. */
+export interface ClientRuntimeRequestFrame {
+  readonly v: typeof INSPECTOR_PROTOCOL_VERSION
+  readonly t: 'client-runtime/request'
+  readonly sourceId: InspectorSourceId
+  readonly generation: InspectorSourceGeneration
+  readonly sessionId: ClientRuntimeSessionId
+  readonly requestId: ClientRuntimeRequestId
+  readonly command: ClientRuntimeCommand
+}
+
+/** Client response to one typed Runtime request. */
+export interface ClientRuntimeResponseFrame {
+  readonly v: typeof INSPECTOR_PROTOCOL_VERSION
+  readonly t: 'client-runtime/response'
+  readonly sourceId: InspectorSourceId
+  readonly generation: InspectorSourceGeneration
+  readonly sessionId: ClientRuntimeSessionId
+  readonly requestId: ClientRuntimeRequestId
+  readonly outcome:
+    | { readonly ok: true; readonly result: ClientRuntimeResult }
+    | { readonly ok: false; readonly error: ClientRuntimeError }
+}
+
+/** One-way cleanup when a DevTools connection or its Runtime domain closes. */
+export interface ClientRuntimeSessionClosedFrame {
+  readonly v: typeof INSPECTOR_PROTOCOL_VERSION
+  readonly t: 'client-runtime/session-closed'
+  readonly sourceId: InspectorSourceId
+  readonly generation: InspectorSourceGeneration
+  readonly sessionId: ClientRuntimeSessionId
+}
+
+/**
+ * Parse and rebuild a Client Runtime capability.
+ * @param value - Untrusted capability declaration.
+ * @returns The validated capability.
+ */
+export function parseClientRuntimeCapability(value: unknown): ClientRuntimeCapability {
+  const record = exactObject(value, ['type', 'origin'], 'Client Runtime capability')
+  if (record.type !== 'client-runtime' || typeof record.origin !== 'string' || record.origin.length > 2_048) {
+    throw new Error('inspector protocol: invalid Client Runtime capability')
+  }
+  return { type: 'client-runtime', origin: record.origin }
+}
+
+/**
+ * Parse and rebuild one Worker-to-Client Runtime request.
+ * @param value - Untrusted request frame.
+ * @returns The validated request frame.
+ */
+export function parseClientRuntimeRequestFrame(value: Record<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',
+])

+ 5 - 0
packages/experimental/inspector/src/shared/bridge/messages/runtime/index.ts

@@ -0,0 +1,5 @@
+/** Public types and boundary decoders for the Client Runtime wire protocol. */
+
+export * from './commands.ts'
+export * from './console-frames.ts'
+export * from './frames.ts'

+ 334 - 0
packages/experimental/inspector/src/shared/bridge/messages/runtime/value-codec.ts

@@ -0,0 +1,334 @@
+/** Exact wire decoder for Client Runtime results and RemoteObject data. */
+
+import { isJsonValue, isPlainObject } from '../../../json.ts'
+import { exactKeys, exactObject, optionalBoolean, optionalString, wireId } from '../../../validation.ts'
+import { parseInspectorObjectReference } from '../../../cordis/object-reference.ts'
+import type {
+  RuntimeCallFrame,
+  RuntimeObjectPreview,
+  RuntimePropertyPreview,
+  RuntimeRemoteObjectDescriptor,
+  RuntimeRemoteObjectSubtype,
+  RuntimeRemoteObjectType,
+  RuntimeStackTrace,
+} from '../../../cdp/index.ts'
+import type {
+  ClientRuntimeCompletion,
+  ClientRuntimeExceptionDetails,
+  ClientRuntimeInternalPropertyDescriptor,
+  ClientRuntimePropertyDescriptor,
+  ClientRuntimeRemoteObject,
+  ClientRuntimeResult,
+} from './commands.ts'
+
+/**
+ * Parse and rebuild one successful Client Runtime result.
+ * @param value - Untrusted result value.
+ * @returns The validated result union member.
+ */
+export function parseClientRuntimeResult(value: unknown): ClientRuntimeResult {
+  if (!isPlainObject(value) || typeof value.op !== 'string') {
+    throw new Error('inspector protocol: Client Runtime result must have an op')
+  }
+  switch (value.op) {
+    case 'evaluate':
+    case 'call-function':
+    case 'await-promise':
+      exactKeys(value, ['op', 'completion'], `${value.op} result`)
+      return { op: value.op, completion: parseCompletion(value.completion) }
+    case 'get-properties': {
+      exactKeys(value, ['op', 'properties', 'internalProperties', 'exceptionDetails'], 'get-properties result')
+      if (!Array.isArray(value.properties)) throw new Error('inspector protocol: properties must be an array')
+      const internal = value.internalProperties
+      if (internal !== undefined && !Array.isArray(internal)) {
+        throw new Error('inspector protocol: internalProperties must be an array')
+      }
+      return {
+        op: 'get-properties',
+        properties: value.properties.map(parsePropertyDescriptor),
+        ...(internal === undefined ? {} : { internalProperties: internal.map(parseInternalPropertyDescriptor) }),
+        ...(value.exceptionDetails === undefined
+          ? {}
+          : { exceptionDetails: parseClientRuntimeExceptionDetails(value.exceptionDetails) }),
+      }
+    }
+    case 'release-object':
+    case 'release-object-group':
+      exactKeys(value, ['op'], `${value.op} result`)
+      return { op: value.op }
+    case 'global-lexical-scope-names':
+      exactKeys(value, ['op', 'names'], 'global-lexical-scope-names result')
+      if (!Array.isArray(value.names) || !value.names.every(name => typeof name === 'string')) {
+        throw new Error('inspector protocol: lexical scope names must be strings')
+      }
+      return { op: 'global-lexical-scope-names', names: value.names }
+    default:
+      throw new Error(`inspector protocol: unknown Client Runtime result ${JSON.stringify(value.op)}`)
+  }
+}
+
+function parseCompletion(value: unknown): ClientRuntimeCompletion {
+  const record = exactObject(value, ['result', 'exceptionDetails'], 'Client Runtime completion')
+  return {
+    result: parseClientRuntimeRemoteObject(record.result),
+    ...(record.exceptionDetails === undefined
+      ? {}
+      : { exceptionDetails: parseClientRuntimeExceptionDetails(record.exceptionDetails) }),
+  }
+}
+
+/**
+ * Decode one Client Runtime object carrying an optional session-local handle.
+ * @param value - Untrusted wire value.
+ * @returns The validated realm-neutral object value.
+ */
+export function parseClientRuntimeRemoteObject(value: unknown): ClientRuntimeRemoteObject {
+  const record = exactObject(value, ['descriptor', 'object', 'semanticReference'], 'Client Runtime object')
+  const descriptor = parseRemoteObjectDescriptor(record.descriptor)
+  const object = record.object === undefined
+    ? undefined
+    : exactObject(record.object, ['handle'], 'Client Runtime object reference')
+  const remote: ClientRuntimeRemoteObject = {
+    descriptor,
+    ...(object === undefined
+      ? {}
+      : { object: { handle: wireId<'ClientRemoteObjectHandle'>(object.handle, 'handle') } }),
+    ...(record.semanticReference === undefined
+      ? {}
+      : { semanticReference: parseInspectorObjectReference(record.semanticReference) }),
+  }
+  validateRemoteObject(remote)
+  return remote
+}
+
+function parseRemoteObjectDescriptor(value: unknown): RuntimeRemoteObjectDescriptor {
+  const record = exactObject(value, [
+    'type', 'subtype', 'className', 'value', 'unserializableValue', 'description', 'preview',
+  ], 'Runtime object descriptor')
+  if (!REMOTE_TYPES.has(record.type as RuntimeRemoteObjectType)) {
+    throw new Error('inspector protocol: invalid Client RemoteObject type')
+  }
+  if (record.subtype !== undefined && !REMOTE_SUBTYPES.has(record.subtype as RuntimeRemoteObjectSubtype)) {
+    throw new Error('inspector protocol: invalid Client RemoteObject subtype')
+  }
+  if (record.value !== undefined && !isJsonValue(record.value)) {
+    throw new Error('inspector protocol: Client RemoteObject value must be JSON')
+  }
+  return {
+    type: record.type as RuntimeRemoteObjectType,
+    ...(record.subtype === undefined ? {} : { subtype: record.subtype as RuntimeRemoteObjectSubtype }),
+    ...optionalString(record, 'className'),
+    ...(record.value === undefined ? {} : { value: record.value }),
+    ...optionalString(record, 'unserializableValue'),
+    ...optionalString(record, 'description'),
+    ...(record.preview === undefined ? {} : { preview: parseObjectPreview(record.preview) }),
+  }
+}
+
+function parseObjectPreview(value: unknown): RuntimeObjectPreview {
+  const record = exactObject(value, ['type', 'subtype', 'description', 'overflow', 'properties'], 'object preview')
+  if (!REMOTE_TYPES.has(record.type as RuntimeRemoteObjectType)
+    || (record.subtype !== undefined && !REMOTE_SUBTYPES.has(record.subtype as RuntimeRemoteObjectSubtype))
+    || typeof record.overflow !== 'boolean'
+    || !Array.isArray(record.properties)) {
+    throw new Error('inspector protocol: invalid object preview')
+  }
+  return {
+    type: record.type as RuntimeRemoteObjectType,
+    ...(record.subtype === undefined ? {} : { subtype: record.subtype as RuntimeRemoteObjectSubtype }),
+    ...optionalString(record, 'description'),
+    overflow: record.overflow,
+    properties: record.properties.map(parsePropertyPreview),
+  }
+}
+
+function parsePropertyPreview(value: unknown): RuntimePropertyPreview {
+  const record = exactObject(value, ['name', 'type', 'value', 'valuePreview', 'subtype'], 'property preview')
+  if (typeof record.name !== 'string'
+    || (record.type !== 'accessor' && !REMOTE_TYPES.has(record.type as RuntimeRemoteObjectType))
+    || (record.subtype !== undefined && !REMOTE_SUBTYPES.has(record.subtype as RuntimeRemoteObjectSubtype))) {
+    throw new Error('inspector protocol: invalid property preview')
+  }
+  return {
+    name: record.name,
+    type: record.type as RuntimePropertyPreview['type'],
+    ...optionalString(record, 'value'),
+    ...(record.valuePreview === undefined ? {} : { valuePreview: parseObjectPreview(record.valuePreview) }),
+    ...(record.subtype === undefined ? {} : { subtype: record.subtype as RuntimeRemoteObjectSubtype }),
+  }
+}
+
+function parsePropertyDescriptor(value: unknown): ClientRuntimePropertyDescriptor {
+  const record = exactObject(value, [
+    'name', 'value', 'writable', 'get', 'set', 'configurable', 'enumerable', 'wasThrown', 'isOwn', 'symbol',
+  ], 'property descriptor')
+  if (typeof record.name !== 'string' || typeof record.configurable !== 'boolean' || typeof record.enumerable !== 'boolean') {
+    throw new Error('inspector protocol: invalid property descriptor')
+  }
+  const dataDescriptor = record.value !== undefined || record.writable !== undefined
+  const accessorDescriptor = record.get !== undefined || record.set !== undefined
+  if (dataDescriptor && accessorDescriptor) {
+    throw new Error('inspector protocol: property descriptor mixes data and accessor fields')
+  }
+  return {
+    name: record.name,
+    ...(record.value === undefined ? {} : { value: parseClientRuntimeRemoteObject(record.value) }),
+    ...optionalBoolean(record, 'writable'),
+    ...(record.get === undefined ? {} : { get: parseClientRuntimeRemoteObject(record.get) }),
+    ...(record.set === undefined ? {} : { set: parseClientRuntimeRemoteObject(record.set) }),
+    configurable: record.configurable,
+    enumerable: record.enumerable,
+    ...optionalBoolean(record, 'wasThrown'),
+    ...optionalBoolean(record, 'isOwn'),
+    ...(record.symbol === undefined ? {} : { symbol: parseClientRuntimeRemoteObject(record.symbol) }),
+  }
+}
+
+function parseInternalPropertyDescriptor(value: unknown): ClientRuntimeInternalPropertyDescriptor {
+  const record = exactObject(value, ['name', 'value'], 'internal property descriptor')
+  if (typeof record.name !== 'string') throw new Error('inspector protocol: invalid internal property descriptor')
+  return {
+    name: record.name,
+    ...(record.value === undefined ? {} : { value: parseClientRuntimeRemoteObject(record.value) }),
+  }
+}
+
+/**
+ * Decode Client exception details used by command results and events.
+ * @param value - Untrusted wire value.
+ * @returns Validated exception details.
+ */
+export function parseClientRuntimeExceptionDetails(value: unknown): ClientRuntimeExceptionDetails {
+  const record = exactObject(value, [
+    'text', 'lineNumber', 'columnNumber', 'url', 'stackTrace', 'exception',
+  ], 'exception details')
+  if (typeof record.text !== 'string'
+    || !Number.isSafeInteger(record.lineNumber)
+    || (record.lineNumber as number) < 0
+    || !Number.isSafeInteger(record.columnNumber)
+    || (record.columnNumber as number) < 0) {
+    throw new Error('inspector protocol: invalid exception details')
+  }
+  return {
+    text: record.text,
+    lineNumber: record.lineNumber as number,
+    columnNumber: record.columnNumber as number,
+    ...optionalString(record, 'url'),
+    ...(record.stackTrace === undefined ? {} : { stackTrace: parseClientRuntimeStackTrace(record.stackTrace) }),
+    ...(record.exception === undefined ? {} : { exception: parseClientRuntimeRemoteObject(record.exception) }),
+  }
+}
+
+/**
+ * Decode a stack trace carried by a Client Runtime or Console frame.
+ * @param value - Untrusted stack-trace value.
+ * @returns The validated realm-neutral stack trace.
+ */
+export function parseClientRuntimeStackTrace(value: unknown): RuntimeStackTrace {
+  const record = exactObject(value, ['description', 'callFrames', 'parent'], 'stack trace')
+  if (!Array.isArray(record.callFrames)) throw new Error('inspector protocol: stack callFrames must be an array')
+  return {
+    ...optionalString(record, 'description'),
+    callFrames: record.callFrames.map(parseCallFrame),
+    ...(record.parent === undefined ? {} : { parent: parseClientRuntimeStackTrace(record.parent) }),
+  }
+}
+
+function parseCallFrame(value: unknown): RuntimeCallFrame {
+  const record = exactObject(value, ['functionName', 'scriptKey', 'url', 'lineNumber', 'columnNumber'], 'stack call frame')
+  if (typeof record.functionName !== 'string'
+    || typeof record.url !== 'string'
+    || !Number.isSafeInteger(record.lineNumber)
+    || !Number.isSafeInteger(record.columnNumber)) {
+    throw new Error('inspector protocol: invalid stack call frame')
+  }
+  return {
+    functionName: record.functionName,
+    ...(record.scriptKey === undefined ? {} : { scriptKey: wireId<'RuntimeScriptKey'>(record.scriptKey, 'scriptKey') }),
+    url: record.url,
+    lineNumber: record.lineNumber as number,
+    columnNumber: record.columnNumber as number,
+  }
+}
+
+const REMOTE_TYPES = new Set<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`)
+  }
+}

+ 127 - 0
packages/experimental/inspector/src/shared/bridge/messages/sources/codec.ts

@@ -0,0 +1,127 @@
+/** Exact decoders for Client source catalog operations and values. */
+
+import { isPlainObject } from '../../../json.ts'
+import type { RuntimeScript } from '../../../cdp/index.ts'
+import { exactKeys, exactObject, optionalBoolean, optionalString, wireId } from '../../../validation.ts'
+import type {
+  ClientScriptDescriptor,
+  ClientSourceCommand,
+  ClientSourceContentKind,
+  ClientSourceResult,
+} from './commands.ts'
+
+/**
+ * Parse one Worker-to-Client source command.
+ * @param value - Untrusted decoded command.
+ * @returns The validated command.
+ */
+export function parseClientSourceCommand(value: unknown): ClientSourceCommand {
+  if (!isPlainObject(value) || typeof value.op !== 'string') {
+    throw new Error('inspector protocol: Client source command must have an op')
+  }
+  if (value.op === 'list-scripts') {
+    exactKeys(value, ['op'], 'Client source list command')
+    return { op: 'list-scripts' }
+  }
+  if (value.op !== 'get-content-chunk') {
+    throw new Error(`inspector protocol: unknown Client source command ${JSON.stringify(value.op)}`)
+  }
+  exactKeys(value, ['op', 'scriptKey', 'content', 'offset', 'maxBytes'], 'Client source chunk command')
+  return {
+    op: 'get-content-chunk',
+    scriptKey: wireId<'RuntimeScriptKey'>(value.scriptKey, 'scriptKey'),
+    content: contentKind(value.content),
+    offset: natural(value.offset, 'offset', true),
+    maxBytes: natural(value.maxBytes, 'maxBytes', false),
+  }
+}
+
+/**
+ * Parse one successful Client source result.
+ * @param value - Untrusted decoded result.
+ * @returns The validated result.
+ */
+export function parseClientSourceResult(value: unknown): ClientSourceResult {
+  if (!isPlainObject(value) || typeof value.op !== 'string') {
+    throw new Error('inspector protocol: Client source result must have an op')
+  }
+  if (value.op === 'list-scripts') {
+    exactKeys(value, ['op', 'scripts'], 'Client source list result')
+    if (!Array.isArray(value.scripts)) throw new Error('inspector protocol: Client source scripts must be an array')
+    return { op: 'list-scripts', scripts: value.scripts.map(parseScript) }
+  }
+  if (value.op !== 'get-content-chunk') {
+    throw new Error(`inspector protocol: unknown Client source result ${JSON.stringify(value.op)}`)
+  }
+  if (value.available === false) {
+    exactKeys(value, ['op', 'scriptKey', 'content', 'available'], 'unavailable Client source chunk')
+    return {
+      op: 'get-content-chunk',
+      scriptKey: wireId<'RuntimeScriptKey'>(value.scriptKey, 'scriptKey'),
+      content: contentKind(value.content),
+      available: false,
+    }
+  }
+  exactKeys(
+    value,
+    ['op', 'scriptKey', 'content', 'available', 'offset', 'nextOffset', 'data', 'eof'],
+    'Client source chunk result',
+  )
+  if (value.available !== true || typeof value.data !== 'string' || typeof value.eof !== 'boolean') {
+    throw new Error('inspector protocol: invalid Client source chunk result')
+  }
+  const offset = natural(value.offset, 'offset', true)
+  const nextOffset = natural(value.nextOffset, 'nextOffset', true)
+  if (nextOffset < offset || !BASE64.test(value.data)) {
+    throw new Error('inspector protocol: invalid Client source chunk data')
+  }
+  return {
+    op: 'get-content-chunk',
+    scriptKey: wireId<'RuntimeScriptKey'>(value.scriptKey, 'scriptKey'),
+    content: contentKind(value.content),
+    available: true,
+    offset,
+    nextOffset,
+    data: value.data,
+    eof: value.eof,
+  }
+}
+
+function parseScript(value: unknown): ClientScriptDescriptor {
+  const record = exactObject(value, [
+    'scriptKey', 'url', 'hash', 'buildId', 'sourceMapUrl', 'startLine', 'startColumn', 'endLine', 'endColumn',
+    'isModule', 'length',
+  ], 'Client script descriptor')
+  if (typeof record.url !== 'string' || record.url.length > 8_192 || typeof record.hash !== 'string') {
+    throw new Error('inspector protocol: invalid Client script identity')
+  }
+  return {
+    scriptKey: wireId<'RuntimeScriptKey'>(record.scriptKey, 'scriptKey'),
+    url: record.url,
+    hash: record.hash,
+    ...optionalString(record, 'buildId'),
+    ...optionalString(record, 'sourceMapUrl'),
+    startLine: natural(record.startLine, 'startLine', true),
+    startColumn: natural(record.startColumn, 'startColumn', true),
+    endLine: natural(record.endLine, 'endLine', true),
+    endColumn: natural(record.endColumn, 'endColumn', true),
+    ...optionalBoolean(record, 'isModule'),
+    ...(record.length === undefined ? {} : { length: natural(record.length, 'length', true) }),
+  } satisfies Omit<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

+ 47 - 0
packages/experimental/inspector/src/shared/bridge/messages/sources/commands.ts

@@ -0,0 +1,47 @@
+/** Operations and values exchanged with a Client realm's read-only source catalog. */
+
+import type { RuntimeScriptKey } from '../../../cdp/ids.ts'
+import type { RuntimeScript } from '../../../cdp/index.ts'
+
+/** Script metadata that excludes the Worker-owned execution-context id. */
+export type ClientScriptDescriptor = Omit<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
+}

+ 143 - 0
packages/experimental/inspector/src/shared/bridge/messages/sources/frames.ts

@@ -0,0 +1,143 @@
+/** Versioned envelopes for Client source catalog operations. */
+
+import type {
+  ClientSourceRequestId,
+  ClientSourceSessionId,
+  InspectorSourceGeneration,
+  InspectorSourceId,
+} from '../../ids.ts'
+import { isPlainObject } from '../../../json.ts'
+import { exactKeys, exactObject, wireId } from '../../../validation.ts'
+import { INSPECTOR_PROTOCOL_VERSION } from '../../version.ts'
+import { parseClientSourceCommand, parseClientSourceResult } from './codec.ts'
+import type { ClientSourceCommand, ClientSourceError, ClientSourceResult } from './commands.ts'
+
+/** Source capability that permits read-only Client script discovery. */
+export interface ClientSourcesCapability {
+  readonly type: 'client-sources'
+}
+
+/** Worker request for one operation in a Client source catalog. */
+export interface ClientSourceRequestFrame {
+  readonly v: typeof INSPECTOR_PROTOCOL_VERSION
+  readonly t: 'client-sources/request'
+  readonly sourceId: InspectorSourceId
+  readonly generation: InspectorSourceGeneration
+  readonly sessionId: ClientSourceSessionId
+  readonly requestId: ClientSourceRequestId
+  readonly command: ClientSourceCommand
+}
+
+/** Client response to one source catalog operation. */
+export interface ClientSourceResponseFrame {
+  readonly v: typeof INSPECTOR_PROTOCOL_VERSION
+  readonly t: 'client-sources/response'
+  readonly sourceId: InspectorSourceId
+  readonly generation: InspectorSourceGeneration
+  readonly sessionId: ClientSourceSessionId
+  readonly requestId: ClientSourceRequestId
+  readonly outcome:
+    | { readonly ok: true; readonly result: ClientSourceResult }
+    | { readonly ok: false; readonly error: ClientSourceError }
+}
+
+/** One-way cleanup for in-flight operations owned by a closed DevTools session. */
+export interface ClientSourceSessionClosedFrame {
+  readonly v: typeof INSPECTOR_PROTOCOL_VERSION
+  readonly t: 'client-sources/session-closed'
+  readonly sourceId: InspectorSourceId
+  readonly generation: InspectorSourceGeneration
+  readonly sessionId: ClientSourceSessionId
+}
+
+/**
+ * Parse the marker capability for a Client source catalog.
+ * @param value - Untrusted capability declaration.
+ * @returns The validated marker capability.
+ */
+export function parseClientSourcesCapability(value: unknown): ClientSourcesCapability {
+  const record = exactObject(value, ['type'], 'Client Sources capability')
+  if (record.type !== 'client-sources') throw new Error('inspector protocol: invalid Client Sources capability')
+  return { type: 'client-sources' }
+}
+
+/**
+ * Parse one Worker-to-Client source request.
+ * @param value - Untrusted decoded request.
+ * @returns The validated request frame.
+ */
+export function parseClientSourceRequestFrame(value: Record<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',
+])

+ 5 - 0
packages/experimental/inspector/src/shared/bridge/messages/sources/index.ts

@@ -0,0 +1,5 @@
+/** Public types and decoders for the Client source catalog protocol. */
+
+export * from './codec.ts'
+export * from './commands.ts'
+export * from './frames.ts'

+ 24 - 0
packages/experimental/inspector/src/shared/bridge/publisher.ts

@@ -0,0 +1,24 @@
+/** Source-side interfaces shared by MessagePort and WebSocket bridge implementations. */
+
+import type { InspectorJsonValue } from '../json.ts'
+import type { InspectorQueryRequester } from './messages/query/commands.ts'
+
+/** Transport-independent observation publisher. */
+export interface InspectorPublisher {
+  /** Publish one validated observation. */
+  publish(topic: string, payload: InspectorJsonValue, monotonicMs?: number): void
+}
+
+/** Publisher that also retains the latest value of stateful observation topics. */
+export interface InspectorStatePublisher extends InspectorPublisher {
+  /**
+   * Replace one topic's retained state and publish the replacement.
+   * @param topic - Domain-owned state topic.
+   * @param payload - Latest JSON state, replayed after source resynchronization.
+   * @param monotonicMs - Source-clock timestamp; defaults to `performance.now()`.
+   */
+  setState(topic: string, payload: InspectorJsonValue, monotonicMs?: number): void
+}
+
+/** Shared capabilities exposed above a Host MessagePort or Client WebSocket carrier. */
+export interface InspectorConnection extends InspectorStatePublisher, InspectorQueryRequester {}

+ 182 - 0
packages/experimental/inspector/src/shared/bridge/rpc.ts

@@ -0,0 +1,182 @@
+/** Shared Host/Client owner of correlated non-CDP query requests. */
+
+import { inspectorId, type InspectorSourceGeneration, type InspectorSourceId } from './ids.ts'
+import { jsonByteLength, type InspectorJsonValue } from '../json.ts'
+import { INSPECTOR_PROTOCOL_VERSION } from './version.ts'
+import type {
+  InspectorQuery,
+  InspectorQueryError,
+  InspectorQueryRequester,
+  InspectorQueryResult,
+  InspectorQueryResultFor,
+} from './messages/query/commands.ts'
+import { isInspectorQueryResponseEnvelope, parseInspectorQueryResponseFrame } from './messages/query/codec.ts'
+import type { InspectorQueryRequestFrame, InspectorQueryRequestId } from './messages/query/frames.ts'
+
+/** Active carrier write used by the shared query owner. */
+export interface InspectorQuerySender {
+  /**
+   * Send one validated query request frame.
+   * @param frame - Request belonging to the active source generation.
+   */
+  send(frame: InspectorQueryRequestFrame): void
+}
+
+/** Bounds applied by one Host or Client query connection. */
+export interface InspectorQueryConnectionOptions {
+  readonly timeoutMs: number
+  readonly maxFrameBytes: number
+}
+
+interface PendingQuery {
+  readonly op: string
+  readonly resolve: (result: InspectorQueryResult) => void
+  readonly reject: (error: Error) => void
+  readonly timer: ReturnType<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))
+}

+ 3 - 0
packages/experimental/inspector/src/shared/bridge/validation.ts

@@ -0,0 +1,3 @@
+/** Bridge-facing exports for the shared untrusted-value validation primitives. */
+
+export * from '../validation.ts'

+ 2 - 0
packages/experimental/inspector/src/shared/bridge/version.ts

@@ -0,0 +1,2 @@
+/** Current Inspector wire version. Pre-release peers reject every other version. */
+export const INSPECTOR_PROTOCOL_VERSION = 0 as const

+ 28 - 0
packages/experimental/inspector/src/shared/cdp/capabilities.ts

@@ -0,0 +1,28 @@
+/** Explicit operation support advertised by each Inspector realm. */
+
+/** Runtime operations implemented by a realm backend. */
+export type RuntimeOperation =
+  | 'evaluate'
+  | 'get-properties'
+  | 'call-function'
+  | 'await-promise'
+  | 'release-object'
+  | 'release-object-group'
+  | 'global-lexical-scope-names'
+
+/** Console operations implemented by a realm backend. */
+export type ConsoleOperation = 'events' | 'exceptions' | 'clear'
+
+/** Source catalog operations implemented by a realm backend. */
+export type SourceOperation = 'catalog' | 'content' | 'source-map'
+
+/** Active debugger operations implemented by a realm backend. */
+export type DebuggerOperation = 'breakpoint' | 'pause' | 'resume' | 'step' | 'call-frame'
+
+/** Complete capability declaration for one inspected realm. */
+export interface InspectorRealmCapabilities {
+  readonly runtime: readonly RuntimeOperation[]
+  readonly console: readonly ConsoleOperation[]
+  readonly sources: readonly SourceOperation[]
+  readonly debugger: readonly DebuggerOperation[]
+}

+ 46 - 0
packages/experimental/inspector/src/shared/cdp/console.ts

@@ -0,0 +1,46 @@
+/** Realm-neutral Console events emitted by Runtime backends. */
+
+import type { RuntimeRemoteObject } from './remote-object.ts'
+import type { RuntimeExceptionDetails, RuntimeStackTrace } from './errors.ts'
+
+/** Console API categories exposed by CDP Runtime. */
+export type RuntimeConsoleType =
+  | 'log'
+  | 'debug'
+  | 'info'
+  | 'error'
+  | 'warning'
+  | 'dir'
+  | 'dirxml'
+  | 'table'
+  | 'trace'
+  | 'clear'
+  | 'startGroup'
+  | 'startGroupCollapsed'
+  | 'endGroup'
+  | 'assert'
+  | 'profile'
+  | 'profileEnd'
+  | 'count'
+  | 'timeEnd'
+
+/** One Console event associated with a single inspected realm. */
+export interface RuntimeConsoleEvent<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> }

+ 78 - 0
packages/experimental/inspector/src/shared/cdp/debugger.ts

@@ -0,0 +1,78 @@
+/** Realm-neutral values used by active debugger backends. */
+
+import type { InspectorJsonValue } from '../json.ts'
+import type { RuntimeScriptKey } from './ids.ts'
+import type { RuntimeStackTrace } from './errors.ts'
+import type { RuntimeCompletion } from './operations.ts'
+import type { RuntimeRemoteObject } from './remote-object.ts'
+
+/** One source location independent of a CDP ScriptId allocation policy. */
+export interface RuntimeDebuggerLocation {
+  readonly scriptKey: RuntimeScriptKey
+  readonly lineNumber: number
+  readonly columnNumber?: number
+}
+
+/** One lexical scope attached to a paused call frame. */
+export interface RuntimeDebuggerScope<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>

+ 30 - 0
packages/experimental/inspector/src/shared/cdp/errors.ts

@@ -0,0 +1,30 @@
+/** Realm-neutral JavaScript exception and stack information. */
+
+import type { RuntimeScriptKey } from './ids.ts'
+import type { RuntimeRemoteObject } from './remote-object.ts'
+
+/** One source location in a Runtime exception stack. */
+export interface RuntimeCallFrame {
+  readonly functionName: string
+  readonly scriptKey?: RuntimeScriptKey
+  readonly url: string
+  readonly lineNumber: number
+  readonly columnNumber: number
+}
+
+/** JavaScript stack information independent of a Debugger script id. */
+export interface RuntimeStackTrace {
+  readonly description?: string
+  readonly callFrames: readonly RuntimeCallFrame[]
+  readonly parent?: RuntimeStackTrace
+}
+
+/** JavaScript exception produced while executing one Runtime command. */
+export interface RuntimeExceptionDetails<Handle extends string> {
+  readonly text: string
+  readonly lineNumber: number
+  readonly columnNumber: number
+  readonly url?: string
+  readonly stackTrace?: RuntimeStackTrace
+  readonly exception?: RuntimeRemoteObject<Handle>
+}

+ 12 - 0
packages/experimental/inspector/src/shared/cdp/ids.ts

@@ -0,0 +1,12 @@
+/** Opaque identifiers owned by normalized realm backends. */
+
+import type { InspectorId } from '../identity.ts'
+
+/** Worker identity of one active Host or Client realm incarnation. */
+export type InspectorRealmId = InspectorId<'InspectorRealmId'>
+
+/** Backend-owned object handle interpreted only by its realm session. */
+export type RuntimeBackendObjectHandle = InspectorId<'RuntimeBackendObjectHandle'>
+
+/** Backend-independent identity of one script in a realm catalog. */
+export type RuntimeScriptKey = InspectorId<'RuntimeScriptKey'>

+ 11 - 0
packages/experimental/inspector/src/shared/cdp/index.ts

@@ -0,0 +1,11 @@
+/** Realm-neutral Runtime, Console, Source, and Debugger protocol types. */
+
+export * from './capabilities.ts'
+export * from './console.ts'
+export * from './debugger.ts'
+export * from './errors.ts'
+export * from './ids.ts'
+export * from './operations.ts'
+export * from './property.ts'
+export * from './remote-object.ts'
+export * from './sources.ts'

+ 80 - 0
packages/experimental/inspector/src/shared/cdp/operations.ts

@@ -0,0 +1,80 @@
+/** Realm-neutral Runtime operations and results. */
+
+import type { InspectorJsonObject, InspectorJsonValue } from '../json.ts'
+import type { RuntimeExceptionDetails } from './errors.ts'
+import type {
+  RuntimeInternalPropertyDescriptor,
+  RuntimePrivatePropertyDescriptor,
+  RuntimePropertyDescriptor,
+} from './property.ts'
+import type { RuntimeRemoteObject } from './remote-object.ts'
+
+/** One argument supplied to a function in an inspected realm. */
+export type RuntimeCallArgument<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>
+}

+ 31 - 0
packages/experimental/inspector/src/shared/cdp/property.ts

@@ -0,0 +1,31 @@
+/** Realm-neutral property descriptors returned by Runtime backends. */
+
+import type { RuntimeRemoteObject } from './remote-object.ts'
+
+/** One JavaScript property descriptor returned without invoking accessors. */
+export interface RuntimePropertyDescriptor<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>
+}

+ 157 - 0
packages/experimental/inspector/src/shared/cdp/realm.ts

@@ -0,0 +1,157 @@
+/** Environment-independent backend interfaces for inspected JavaScript realms. */
+
+import type { RuntimeBackendObjectHandle, RuntimeScriptKey } from './ids.ts'
+import type {
+  RuntimeAwaitPromiseRequest,
+  RuntimeCallFunctionRequest,
+  RuntimeCompletion,
+  RuntimeConsoleBackendEvent,
+  RuntimeDebuggerEvent,
+  RuntimeDebuggerEnableRequest,
+  RuntimeDebuggerResumeRequest,
+  RuntimeCallFrameEvaluationRequest,
+  RuntimeEvaluateRequest,
+  RuntimeGetPropertiesRequest,
+  RuntimeProperties,
+  RuntimeScript,
+} from './index.ts'
+
+/** Raw notification emitted by a native engine protocol backend. */
+export interface NativeProtocolNotification {
+  readonly method: string
+  readonly params?: Readonly<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
+}

+ 78 - 0
packages/experimental/inspector/src/shared/cdp/remote-object.ts

@@ -0,0 +1,78 @@
+/** Realm-neutral JavaScript value descriptions used by Inspector backends. */
+
+import type { InspectorObjectReference } from '../cordis/object-reference.ts'
+import type { InspectorJsonValue } from '../json.ts'
+
+/** Runtime value kinds represented by CDP `Runtime.RemoteObject`. */
+export type RuntimeRemoteObjectType =
+  | 'object'
+  | 'function'
+  | 'undefined'
+  | 'string'
+  | 'number'
+  | 'boolean'
+  | 'symbol'
+  | 'bigint'
+
+/** Runtime object subtype hints understood by Chrome DevTools. */
+export type RuntimeRemoteObjectSubtype =
+  | 'array'
+  | 'null'
+  | 'node'
+  | 'regexp'
+  | 'date'
+  | 'map'
+  | 'set'
+  | 'weakmap'
+  | 'weakset'
+  | 'iterator'
+  | 'generator'
+  | 'error'
+  | 'proxy'
+  | 'promise'
+  | 'typedarray'
+  | 'arraybuffer'
+  | 'dataview'
+  | 'webassemblymemory'
+  | 'wasmvalue'
+
+/** Shallow property rendered inline by DevTools. */
+export interface RuntimePropertyPreview {
+  readonly name: string
+  readonly type: RuntimeRemoteObjectType | 'accessor'
+  readonly value?: string
+  readonly valuePreview?: RuntimeObjectPreview
+  readonly subtype?: RuntimeRemoteObjectSubtype
+}
+
+/** Shallow object rendering that never carries a live-object reference. */
+export interface RuntimeObjectPreview {
+  readonly type: RuntimeRemoteObjectType
+  readonly subtype?: RuntimeRemoteObjectSubtype
+  readonly description?: string
+  readonly overflow: boolean
+  readonly properties: readonly RuntimePropertyPreview[]
+}
+
+/** Engine-independent description of one JavaScript value. */
+export interface RuntimeRemoteObjectDescriptor {
+  readonly type: RuntimeRemoteObjectType
+  readonly subtype?: RuntimeRemoteObjectSubtype
+  readonly className?: string
+  readonly value?: InspectorJsonValue
+  readonly unserializableValue?: string
+  readonly description?: string
+  readonly preview?: RuntimeObjectPreview
+}
+
+/** Backend-owned reference to a retained object in one realm session. */
+export interface RuntimeBackendObjectReference<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
+}

+ 19 - 0
packages/experimental/inspector/src/shared/cdp/sources.ts

@@ -0,0 +1,19 @@
+/** Realm-neutral script metadata used by source backends. */
+
+import type { RuntimeScriptKey } from './ids.ts'
+
+/** One script visible in a realm's source catalog. */
+export interface RuntimeScript {
+  readonly scriptKey: RuntimeScriptKey
+  readonly url: string
+  readonly hash: string
+  readonly buildId?: string
+  readonly sourceMapUrl?: string
+  readonly startLine: number
+  readonly startColumn: number
+  readonly endLine: number
+  readonly endColumn: number
+  readonly executionContextId?: number
+  readonly isModule?: boolean
+  readonly length?: number
+}

+ 19 - 0
packages/experimental/inspector/src/shared/identity.ts

@@ -0,0 +1,19 @@
+/** Shared branded-identifier construction without assigning protocol ownership. */
+
+import type { Branded } from '@deepseek-ai/dsh-brand'
+
+/** String branded with one Inspector identity role. */
+export type InspectorId<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>
+}

+ 18 - 0
packages/experimental/inspector/src/shared/index.ts

@@ -0,0 +1,18 @@
+/** Environment-independent Inspector models and bridge protocol exports. */
+
+export * from './bridge/messages/control.ts'
+export * from './bridge/control-codec.ts'
+export * from './cordis/snapshot.ts'
+export * from './bridge/messages/cordis.ts'
+export * from './bridge/messages/runtime/index.ts'
+export * from './bridge/messages/sources/index.ts'
+export * from './bridge/messages/network.ts'
+export * from './network/observation.ts'
+export * from './bridge/ids.ts'
+export * from './json.ts'
+export * from './cordis/object-reference.ts'
+export * from './bridge/messages/query/index.ts'
+export * from './bridge/query-reader.ts'
+export * from './bridge/rpc.ts'
+export * from './cdp/index.ts'
+export * from './bridge/messages/observation.ts'

+ 79 - 0
packages/experimental/inspector/src/shared/json.ts

@@ -0,0 +1,79 @@
+/** JSON values admitted by every Inspector cross-realm message. */
+
+/** JSON scalar accepted by Inspector transports. */
+export type InspectorJsonPrimitive = null | boolean | number | string
+
+/** Recursively JSON-compatible value accepted by Inspector transports. */
+export type InspectorJsonValue =
+  | InspectorJsonPrimitive
+  | readonly InspectorJsonValue[]
+  | InspectorJsonObject
+
+/** JSON-compatible object accepted by Inspector transports. */
+export interface InspectorJsonObject {
+  readonly [key: string]: InspectorJsonValue
+}
+
+/**
+ * Test that a value can cross both MessagePort and JSON WebSocket carriers without coercion.
+ * @param value - Candidate wire value.
+ * @returns Whether the value is lossless JSON data.
+ */
+export function isJsonValue(value: unknown): value is InspectorJsonValue {
+  return visitJson(value, new Set<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)
+  }
+}

+ 93 - 0
packages/experimental/inspector/src/shared/validation.ts

@@ -0,0 +1,93 @@
+/** Shared exact-object readers for versioned Inspector wire protocols. */
+
+import { inspectorId, type InspectorId } from './identity.ts'
+import { isPlainObject } from './json.ts'
+
+/**
+ * Require a plain object containing only the listed fields.
+ * @param value - Candidate object.
+ * @param keys - Complete field allowlist.
+ * @param label - Object name used in validation errors.
+ * @returns The validated plain object.
+ */
+export function exactObject(value: unknown, keys: readonly string[], label: string): Record<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 }
+}

+ 265 - 0
packages/experimental/inspector/tests/protocol.host.spec.ts

@@ -0,0 +1,265 @@
+/** Worker and shared protocol behavior. */
+
+import { describe, expect, it, vi } from 'vitest'
+import { INSPECTOR_PROTOCOL_VERSION, parseSourceFrame, parseWorkerSourceFrame } from '../src/shared/bridge/messages/observation.ts'
+import { InspectorSourceRegistry, type InspectorRecordConsumer, type SourceConnection } from '../src/worker/bridge/hub.ts'
+
+describe('Inspector source protocol', () => {
+  it('rebuilds a valid source frame and rejects non-JSON payloads', () => {
+    const frame = parseSourceFrame({
+      v: INSPECTOR_PROTOCOL_VERSION,
+      t: 'source/append',
+      sourceId: 'host-1',
+      generation: 'generation-1',
+      firstSequence: 1,
+      droppedBefore: 0,
+      records: [{ monotonicMs: 12, topic: 'probe', payload: { ok: true } }],
+    }, 4)
+    expect(frame.t).toBe('source/append')
+    expect(() => parseSourceFrame({
+      v: INSPECTOR_PROTOCOL_VERSION,
+      t: 'source/append',
+      sourceId: 'host-1',
+      generation: 'generation-1',
+      firstSequence: 1,
+      droppedBefore: 0,
+      records: [{ monotonicMs: 12, topic: 'probe', payload: { bad: undefined } }],
+    }, 4)).toThrow('lossless JSON object')
+  })
+
+  it('isolates generations and reports sequence gaps', () => {
+    const replace = vi.fn()
+    const append = vi.fn()
+    const close = vi.fn()
+    const consumer: InspectorRecordConsumer = {
+      topics: new Set(['probe']),
+      replace,
+      append,
+      close,
+    }
+    const replies: unknown[] = []
+    const send = vi.fn((frame: unknown) => { replies.push(frame) })
+    const closeConnection = vi.fn()
+    const connection: SourceConnection = {
+      kind: 'host',
+      send,
+      close: closeConnection,
+    }
+    const registry = new InspectorSourceRegistry([consumer], 16_384, 4)
+    registry.receive(connection, {
+      v: 0,
+      t: 'source/open',
+      source: {
+        sourceId: 'host-1',
+        generation: 'g-1',
+        kind: 'host',
+        label: 'Host',
+        timeOriginMs: 1_000,
+        capabilities: [],
+      },
+      topics: ['probe'],
+    })
+    registry.receive(connection, {
+      v: 0,
+      t: 'source/append',
+      sourceId: 'host-1',
+      generation: 'g-1',
+      firstSequence: 2,
+      droppedBefore: 1,
+      records: [{ monotonicMs: 1, topic: 'probe', payload: { value: 1 } }],
+    })
+
+    expect(append).toHaveBeenCalledOnce()
+    expect(registry.describe()[0]).toMatchObject({ expectedSequence: 3, dropped: 1, topics: { probe: 1 } })
+
+    registry.receive(connection, {
+      v: 0,
+      t: 'source/append',
+      sourceId: 'host-1',
+      generation: 'g-1',
+      firstSequence: 5,
+      droppedBefore: 0,
+      records: [],
+    })
+    expect(replies.at(-1)).toMatchObject({ t: 'source/resnapshot', expectedSequence: 3 })
+    expect(append).toHaveBeenCalledOnce()
+  })
+
+  it('closes only a malformed source connection', () => {
+    const send = vi.fn()
+    const closeConnection = vi.fn()
+    const connection: SourceConnection = {
+      kind: 'client',
+      send,
+      close: closeConnection,
+    }
+    const registry = new InspectorSourceRegistry([], 1_024, 2)
+    registry.receive(connection, { v: 99, t: 'source/open' })
+    expect(send).toHaveBeenCalledWith(expect.objectContaining({ t: 'source/rejected' }))
+    expect(closeConnection).toHaveBeenCalledOnce()
+  })
+
+  it('decodes Runtime commands and rejects undeclared fields', () => {
+    const request = parseWorkerSourceFrame({
+      v: 0,
+      t: 'client-runtime/request',
+      sourceId: 'client-1',
+      generation: 'g-1',
+      sessionId: 'session-1',
+      requestId: 'request-1',
+      command: {
+        op: 'call-function',
+        functionDeclaration: 'function () { return this.value }',
+        receiver: 'object-1',
+        arguments: [{ kind: 'unserializable', value: 'NaN' }],
+        returnByValue: true,
+      },
+    })
+    expect(request).toMatchObject({
+      t: 'client-runtime/request',
+      command: { op: 'call-function', receiver: 'object-1', returnByValue: true },
+    })
+    if (request.t !== 'client-runtime/request') throw new Error('unexpected frame type')
+    expect(() => parseWorkerSourceFrame({
+      ...request,
+      command: { ...request.command, unversionedExtension: true },
+    })).toThrow('unknown field')
+  })
+
+  it('rejects invalid RemoteObject representations', () => {
+    expect(() => parseSourceFrame({
+      v: 0,
+      t: 'client-runtime/response',
+      sourceId: 'client-1',
+      generation: 'g-1',
+      sessionId: 'session-1',
+      requestId: 'request-1',
+      outcome: {
+        ok: true,
+        result: {
+          op: 'evaluate',
+          completion: {
+            result: {
+              descriptor: { type: 'number', value: 1 },
+              object: { handle: 'object-1' },
+            },
+          },
+        },
+      },
+    }, 4)).toThrow('invalid number RemoteObject representation')
+  })
+
+  it('decodes exact Client Console lifecycle and event frames', () => {
+    expect(parseWorkerSourceFrame({
+      v: 0,
+      t: 'client-console/enable',
+      sourceId: 'client-1',
+      generation: 'g-1',
+      sessionId: 'session-1',
+    })).toMatchObject({ t: 'client-console/enable', sessionId: 'session-1' })
+
+    const frame = parseSourceFrame({
+      v: 0,
+      t: 'client-console/event',
+      sourceId: 'client-1',
+      generation: 'g-1',
+      sessionId: 'session-1',
+      event: {
+        type: 'console-api',
+        event: {
+          type: 'log',
+          arguments: [{
+            descriptor: { type: 'object', className: 'Object', description: 'Object' },
+            object: { handle: 'object-1' },
+          }],
+          timestamp: 12,
+        },
+      },
+    }, 4)
+    expect(frame).toMatchObject({
+      t: 'client-console/event',
+      sessionId: 'session-1',
+      event: {
+        type: 'console-api',
+        event: { type: 'log', arguments: [{ object: { handle: 'object-1' } }] },
+      },
+    })
+
+    expect(() => parseWorkerSourceFrame({
+      v: 0,
+      t: 'client-console/disable',
+      sourceId: 'client-1',
+      generation: 'g-1',
+      sessionId: 'session-1',
+      extra: true,
+    })).toThrow('unknown field')
+  })
+
+  it('decodes bounded Client source commands and responses', () => {
+    expect(parseWorkerSourceFrame({
+      v: 0,
+      t: 'client-sources/request',
+      sourceId: 'client-1',
+      generation: 'g-1',
+      sessionId: 'source-session-1',
+      requestId: 'source-request-1',
+      command: {
+        op: 'get-content-chunk',
+        scriptKey: 'bundle',
+        content: 'source',
+        offset: 0,
+        maxBytes: 1024,
+      },
+    })).toMatchObject({
+      t: 'client-sources/request',
+      command: { op: 'get-content-chunk', maxBytes: 1024 },
+    })
+
+    expect(parseSourceFrame({
+      v: 0,
+      t: 'client-sources/response',
+      sourceId: 'client-1',
+      generation: 'g-1',
+      sessionId: 'source-session-1',
+      requestId: 'source-request-1',
+      outcome: {
+        ok: true,
+        result: {
+          op: 'get-content-chunk',
+          scriptKey: 'bundle',
+          content: 'source',
+          available: true,
+          offset: 0,
+          nextOffset: 3,
+          data: 'YWJj',
+          eof: true,
+        },
+      },
+    }, 4)).toMatchObject({
+      t: 'client-sources/response',
+      outcome: { ok: true, result: { data: 'YWJj', eof: true } },
+    })
+
+    expect(() => parseSourceFrame({
+      v: 0,
+      t: 'client-sources/response',
+      sourceId: 'client-1',
+      generation: 'g-1',
+      sessionId: 'source-session-1',
+      requestId: 'source-request-1',
+      outcome: {
+        ok: true,
+        result: {
+          op: 'get-content-chunk',
+          scriptKey: 'bundle',
+          content: 'source',
+          available: true,
+          offset: 0,
+          nextOffset: 3,
+          data: 'not base64',
+          eof: true,
+        },
+      },
+    }, 4)).toThrow('chunk data')
+  })
+})

+ 43 - 0
packages/experimental/inspector/tests/source-buffer.host.spec.ts

@@ -0,0 +1,43 @@
+/** Worker-side source buffer behavior. */
+
+import { describe, expect, it } from 'vitest'
+import { inspectorId } from '../src/shared/bridge/ids.ts'
+import { InspectorSourceBuffer } from '../src/shared/bridge/buffer.ts'
+
+const sourceId = inspectorId<'InspectorSourceId'>('source-buffer-test', 'sourceId')
+const generation = inspectorId<'InspectorSourceGeneration'>('generation-buffer-test', 'generation')
+
+function buffer(maxQueuedRecords = 2): InspectorSourceBuffer {
+  return new InspectorSourceBuffer({
+    topics: ['*'],
+    maxQueuedRecords,
+    maxQueuedBytes: 32_768,
+    maxRecordsPerFrame: 8,
+    maxFrameBytes: 32_768,
+  })
+}
+
+describe('Inspector source buffer', () => {
+  it('absorbs pre-replacement queue loss exactly once', () => {
+    const records = buffer(1)
+    records.publish('test/event', { ordinal: 1 }, 1)
+    records.publish('test/event', { ordinal: 2 }, 2)
+
+    expect(records.replacement(sourceId, generation)).toMatchObject({
+      nextSequence: 2,
+      records: [],
+    })
+    expect(records.takeBatch(sourceId, generation)).toMatchObject({
+      firstSequence: 2,
+      droppedBefore: 0,
+      records: [{ topic: 'test/event', payload: { ordinal: 2 } }],
+    })
+  })
+
+  it('validates records before either carrier can enqueue them', () => {
+    const records = buffer()
+
+    expect(() => { records.publish('', {}, 1) }).toThrow('topic must contain 1 to 128 characters')
+    expect(() => { records.publish('test/event', {}, Number.NaN) }).toThrow('monotonicMs must be finite')
+  })
+})

+ 96 - 0
packages/experimental/inspector/tsconfig.client.json

@@ -0,0 +1,96 @@
+{
+  "extends": "../../../tsconfig.base.client.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types",
+    "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo"
+  },
+  "files": [
+    "src/client/bridge/controller.ts",
+    "src/client/bridge/dispatcher.ts",
+    "src/client/bridge/lifecycle.ts",
+    "src/client/bridge/publisher.ts",
+    "src/client/bridge/rpc.ts",
+    "src/client/bridge/transport.ts",
+    "src/client/cdp/console.ts",
+    "src/client/cdp/debugger.ts",
+    "src/client/cdp/errors.ts",
+    "src/client/cdp/heap-profiler.ts",
+    "src/client/cdp/index.ts",
+    "src/client/cdp/objects.ts",
+    "src/client/cdp/profiler.ts",
+    "src/client/cdp/properties.ts",
+    "src/client/cdp/runtime.ts",
+    "src/client/cdp/sources.ts",
+    "src/client/cdp/stack.ts",
+    "src/client/index.ts",
+    "src/client/inspection/cordis.ts",
+    "src/client/inspection/network.ts",
+    "src/client/inspection/realm.ts",
+    "src/client/plugin.ts",
+    "src/shared/bridge/buffer.ts",
+    "src/shared/bridge/codec.ts",
+    "src/shared/bridge/control-codec.ts",
+    "src/shared/bridge/ids.ts",
+    "src/shared/bridge/messages/control.ts",
+    "src/shared/bridge/messages/cordis.ts",
+    "src/shared/bridge/messages/network.ts",
+    "src/shared/bridge/messages/observation.ts",
+    "src/shared/bridge/messages/query/codec.ts",
+    "src/shared/bridge/messages/query/commands.ts",
+    "src/shared/bridge/messages/query/frames.ts",
+    "src/shared/bridge/messages/query/index.ts",
+    "src/shared/bridge/messages/runtime/command-codec.ts",
+    "src/shared/bridge/messages/runtime/commands.ts",
+    "src/shared/bridge/messages/runtime/console-frames.ts",
+    "src/shared/bridge/messages/runtime/frames.ts",
+    "src/shared/bridge/messages/runtime/index.ts",
+    "src/shared/bridge/messages/runtime/value-codec.ts",
+    "src/shared/bridge/messages/sources/codec.ts",
+    "src/shared/bridge/messages/sources/commands.ts",
+    "src/shared/bridge/messages/sources/frames.ts",
+    "src/shared/bridge/messages/sources/index.ts",
+    "src/shared/bridge/publisher.ts",
+    "src/shared/bridge/query-reader.ts",
+    "src/shared/bridge/rpc.ts",
+    "src/shared/bridge/validation.ts",
+    "src/shared/bridge/version.ts",
+    "src/shared/cdp/capabilities.ts",
+    "src/shared/cdp/console.ts",
+    "src/shared/cdp/debugger.ts",
+    "src/shared/cdp/errors.ts",
+    "src/shared/cdp/ids.ts",
+    "src/shared/cdp/index.ts",
+    "src/shared/cdp/operations.ts",
+    "src/shared/cdp/property.ts",
+    "src/shared/cdp/realm.ts",
+    "src/shared/cdp/remote-object.ts",
+    "src/shared/cdp/sources.ts",
+    "src/shared/cordis/collector.ts",
+    "src/shared/cordis/ids.ts",
+    "src/shared/cordis/model.ts",
+    "src/shared/cordis/object-reference.ts",
+    "src/shared/cordis/object-registry.ts",
+    "src/shared/cordis/observer.ts",
+    "src/shared/cordis/projector.ts",
+    "src/shared/cordis/reader.ts",
+    "src/shared/cordis/snapshot.ts",
+    "src/shared/identity.ts",
+    "src/shared/index.ts",
+    "src/shared/json.ts",
+    "src/shared/network/observation.ts",
+    "src/shared/service.ts",
+    "src/shared/validation.ts"
+  ],
+  "references": [
+    {
+      "path": "../../util/brand"
+    },
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../util/crypto"
+    }
+  ]
+}

+ 155 - 0
packages/experimental/inspector/tsconfig.host.json

@@ -0,0 +1,155 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types",
+    "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo"
+  },
+  "files": [
+    "src/host/bridge/controller.ts",
+    "src/host/bridge/dispatcher.ts",
+    "src/host/bridge/lifecycle.ts",
+    "src/host/bridge/publisher.ts",
+    "src/host/bridge/rpc.ts",
+    "src/host/bridge/transport.ts",
+    "src/host/cdp/console.ts",
+    "src/host/cdp/debugger.ts",
+    "src/host/cdp/errors.ts",
+    "src/host/cdp/heap-profiler.ts",
+    "src/host/cdp/index.ts",
+    "src/host/cdp/objects.ts",
+    "src/host/cdp/profiler.ts",
+    "src/host/cdp/properties.ts",
+    "src/host/cdp/runtime.ts",
+    "src/host/cdp/sources.ts",
+    "src/host/cdp/stack.ts",
+    "src/host/index.ts",
+    "src/host/inspection/cordis.ts",
+    "src/host/inspection/network.ts",
+    "src/host/inspection/realm.ts",
+    "src/host/plugin.ts",
+    "src/index.ts",
+    "src/invariant.ts",
+    "src/shared/bridge/buffer.ts",
+    "src/shared/bridge/codec.ts",
+    "src/shared/bridge/control-codec.ts",
+    "src/shared/bridge/ids.ts",
+    "src/shared/bridge/messages/control.ts",
+    "src/shared/bridge/messages/cordis.ts",
+    "src/shared/bridge/messages/network.ts",
+    "src/shared/bridge/messages/observation.ts",
+    "src/shared/bridge/messages/query/codec.ts",
+    "src/shared/bridge/messages/query/commands.ts",
+    "src/shared/bridge/messages/query/frames.ts",
+    "src/shared/bridge/messages/query/index.ts",
+    "src/shared/bridge/messages/runtime/command-codec.ts",
+    "src/shared/bridge/messages/runtime/commands.ts",
+    "src/shared/bridge/messages/runtime/console-frames.ts",
+    "src/shared/bridge/messages/runtime/frames.ts",
+    "src/shared/bridge/messages/runtime/index.ts",
+    "src/shared/bridge/messages/runtime/value-codec.ts",
+    "src/shared/bridge/messages/sources/codec.ts",
+    "src/shared/bridge/messages/sources/commands.ts",
+    "src/shared/bridge/messages/sources/frames.ts",
+    "src/shared/bridge/messages/sources/index.ts",
+    "src/shared/bridge/publisher.ts",
+    "src/shared/bridge/query-reader.ts",
+    "src/shared/bridge/rpc.ts",
+    "src/shared/bridge/validation.ts",
+    "src/shared/bridge/version.ts",
+    "src/shared/cdp/capabilities.ts",
+    "src/shared/cdp/console.ts",
+    "src/shared/cdp/debugger.ts",
+    "src/shared/cdp/errors.ts",
+    "src/shared/cdp/ids.ts",
+    "src/shared/cdp/index.ts",
+    "src/shared/cdp/operations.ts",
+    "src/shared/cdp/property.ts",
+    "src/shared/cdp/realm.ts",
+    "src/shared/cdp/remote-object.ts",
+    "src/shared/cdp/sources.ts",
+    "src/shared/cordis/collector.ts",
+    "src/shared/cordis/ids.ts",
+    "src/shared/cordis/model.ts",
+    "src/shared/cordis/object-reference.ts",
+    "src/shared/cordis/object-registry.ts",
+    "src/shared/cordis/observer.ts",
+    "src/shared/cordis/projector.ts",
+    "src/shared/cordis/reader.ts",
+    "src/shared/cordis/snapshot.ts",
+    "src/shared/identity.ts",
+    "src/shared/index.ts",
+    "src/shared/json.ts",
+    "src/shared/network/observation.ts",
+    "src/shared/service.ts",
+    "src/shared/validation.ts",
+    "src/worker/bridge/endpoint.ts",
+    "src/worker/bridge/hub.ts",
+    "src/worker/bridge/runtime-rpc.ts",
+    "src/worker/bridge/session.ts",
+    "src/worker/bridge/source-rpc.ts",
+    "src/worker/cdp/domains/debugger/cdp-params.ts",
+    "src/worker/cdp/domains/debugger/index.ts",
+    "src/worker/cdp/domains/debugger/projector.ts",
+    "src/worker/cdp/domains/debugger/script-registry.ts",
+    "src/worker/cdp/domains/debugger/session.ts",
+    "src/worker/cdp/domains/dom/index.ts",
+    "src/worker/cdp/domains/dom/model.ts",
+    "src/worker/cdp/domains/dom/session.ts",
+    "src/worker/cdp/domains/native.ts",
+    "src/worker/cdp/domains/network/session.ts",
+    "src/worker/cdp/domains/runtime/cdp-params.ts",
+    "src/worker/cdp/domains/runtime/index.ts",
+    "src/worker/cdp/domains/runtime/object-table.ts",
+    "src/worker/cdp/domains/runtime/session.ts",
+    "src/worker/cdp/ids.ts",
+    "src/worker/cdp/protocol.ts",
+    "src/worker/cdp/realm-sessions.ts",
+    "src/worker/cdp/session.ts",
+    "src/worker/cdp/target.ts",
+    "src/worker/entry.ts",
+    "src/worker/inspection/cordis-query.ts",
+    "src/worker/inspection/cordis-store.ts",
+    "src/worker/inspection/network-store.ts",
+    "src/worker/inspection/query-router.ts",
+    "src/worker/inspection/realm-store.ts",
+    "src/worker/inspection/realm.ts",
+    "src/worker/realms/client/bridge.ts",
+    "src/worker/realms/client/console.ts",
+    "src/worker/realms/client/debugger.ts",
+    "src/worker/realms/client/index.ts",
+    "src/worker/realms/client/runtime.ts",
+    "src/worker/realms/client/scripts.ts",
+    "src/worker/realms/client/sources.ts",
+    "src/worker/realms/client/values.ts",
+    "src/worker/realms/host/bridge.ts",
+    "src/worker/realms/host/console.ts",
+    "src/worker/realms/host/debugger.ts",
+    "src/worker/realms/host/index.ts",
+    "src/worker/realms/host/runtime.ts",
+    "src/worker/realms/host/scripts.ts",
+    "src/worker/realms/host/sources.ts",
+    "src/worker/realms/host/values.ts",
+    "src/worker/server.ts"
+  ],
+  "references": [
+    {
+      "path": "../../util/brand"
+    },
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../../vendor/schemastery"
+    },
+    {
+      "path": "../../host/webserver"
+    },
+    {
+      "path": "../../runtime-diagnostics/invariants"
+    },
+    {
+      "path": "../../util/crypto"
+    }
+  ]
+}

+ 11 - 0
packages/experimental/inspector/tsconfig.json

@@ -0,0 +1,11 @@
+{
+  "files": [],
+  "references": [
+    {
+      "path": "./tsconfig.host.json"
+    },
+    {
+      "path": "./tsconfig.client.json"
+    }
+  ]
+}

+ 22 - 0
packages/experimental/inspector/tsdown.config.ts

@@ -0,0 +1,22 @@
+import type { UserConfig } from 'tsdown'
+import { clientBundle } from '../../client/tsdown.client.ts'
+
+const worker: UserConfig = {
+  entry: { worker: 'lib/types/worker/entry.js' },
+  outDir: 'lib',
+  format: ['esm'],
+  platform: 'node',
+  target: 'es2024',
+  fixedExtension: false,
+  dts: false,
+  clean: false,
+  outputOptions: { inlineDynamicImports: true },
+  deps: { neverBundle: specifier => specifier === 'ws' },
+}
+
+/** Build the Host plugin and Worker during the Host pass, and the dynamic Client plugin during the Client pass. */
+export default clientBundle(
+  '@deepseek-ai/dsh-experimental-inspector',
+  ['lib/types/index.js', 'lib/types/invariant.js'],
+  { hostPhase: true, companions: [worker] },
+)

+ 43 - 0
pnpm-lock.yaml

@@ -4794,6 +4794,49 @@ importers:
         specifier: ^18.2.0
         version: 18.3.1(react@18.3.1)
 
+  packages/experimental/inspector:
+    dependencies:
+      '@deepseek-ai/dsh-brand':
+        specifier: workspace:^
+        version: link:../../util/brand
+      '@deepseek-ai/dsh-client-modules':
+        specifier: workspace:^
+        version: link:../../client/modules
+      '@deepseek-ai/dsh-util-crypto':
+        specifier: workspace:^
+        version: link:../../util/crypto
+      '@deepseek-ai/schemastery':
+        specifier: link:../../../vendor/schemastery
+        version: link:../../../vendor/schemastery
+      ws:
+        specifier: ^8.21.0
+        version: 8.21.0
+    devDependencies:
+      '@deepseek-ai/cordis':
+        specifier: workspace:^
+        version: link:../../../vendor/cordis
+      '@deepseek-ai/cordis-plugin-include':
+        specifier: workspace:^
+        version: link:../../../vendor/include
+      '@deepseek-ai/cordis-plugin-loader':
+        specifier: workspace:^
+        version: link:../../../vendor/loader
+      '@deepseek-ai/dsh-host-webserver':
+        specifier: workspace:^
+        version: link:../../host/webserver
+      '@deepseek-ai/dsh-invariants':
+        specifier: workspace:^
+        version: link:../../runtime-diagnostics/invariants
+      '@types/ws':
+        specifier: ^8.18.1
+        version: 8.18.1
+      playwright:
+        specifier: ^1.49.0
+        version: 1.61.1
+      tsx:
+        specifier: ^4.19.2
+        version: 4.22.4
+
   packages/experimental/tool-agent-team:
     dependencies:
       '@deepseek-ai/schemastery':

+ 3 - 0
tsconfig.base.json

@@ -125,6 +125,7 @@
       "@deepseek-ai/dsh-experimental-tool-agent-team/invariant": ["./packages/experimental/tool-agent-team/src/invariant.ts"],
       "@deepseek-ai/dsh-experimental-webworker-runtime/invariant": ["./packages/experimental/webworker-runtime/src/invariant.ts"],
       "@deepseek-ai/dsh-experimental-webworker-packer/invariant": ["./packages/experimental/webworker-packer/src/invariant.ts"],
+      "@deepseek-ai/dsh-experimental-inspector/invariant": ["./packages/experimental/inspector/src/invariant.ts"],
       "@deepseek-ai/dsh-util-crypto/invariant": ["./packages/util/crypto/src/invariant.ts"],
       "@deepseek-ai/dsh-*/invariant": [
         "./packages/core/*/src/invariant.ts",
@@ -271,6 +272,8 @@
       "@deepseek-ai/dsh-experimental-tool-agent-team": ["./packages/experimental/tool-agent-team/src"],
       "@deepseek-ai/dsh-experimental-webworker-runtime": ["./packages/experimental/webworker-runtime/src"],
       "@deepseek-ai/dsh-experimental-webworker-packer": ["./packages/experimental/webworker-packer/src"],
+      "@deepseek-ai/dsh-experimental-inspector": ["./packages/experimental/inspector/src"],
+      "@deepseek-ai/dsh-experimental-inspector/client": ["./packages/experimental/inspector/src/client/index.ts"],
       "@deepseek-ai/dsh-util-crypto": ["./packages/util/crypto/src"],
       "@deepseek-ai/dsh-*": [
         "./packages/core/*/src",

+ 1 - 0
tsconfig.client.json

@@ -51,6 +51,7 @@
     { "path": "./packages/client/ui-attachment" },
     { "path": "./packages/client/ui-primitives" },
     { "path": "./packages/client/modules" },
+    { "path": "./packages/experimental/inspector/tsconfig.client.json" },
     { "path": "./packages/client/hmr" },
     { "path": "./packages/client/connection/tsconfig.client.json" },
     { "path": "./packages/typert/registry" },

+ 1 - 0
tsconfig.host.json

@@ -276,6 +276,7 @@
     { "path": "./packages/test-support/loader-smoke" },
     { "path": "./packages/test-support/llm-mock-server" },
     { "path": "./packages/experimental/webworker-packer" },
+    { "path": "./packages/experimental/inspector/tsconfig.host.json" },
     { "path": "./packages/subagent/subagent" },
     { "path": "./packages/subagent/tool-subagent" },
     { "path": "./packages/subagent/tool-subagent-control" },

+ 6 - 0
vitest.config.ts

@@ -251,6 +251,12 @@ export default defineConfig({
         // coverage lane exists.
         'packages/experimental/webworker-runtime/src/**',
         'packages/experimental/webworker-packer/src/*',
+        // Inspector behavior spans a Node Worker, the Host isolate, and a real
+        // browser realm. Its focused specs cover pure logic, while its Worker,
+        // Debugger, Chromium, and Loader suites run the assembled paths that
+        // the parent Vitest process cannot attribute. TODO(inspector): remove
+        // when the coverage lane can merge cross-realm V8 coverage.
+        'packages/experimental/inspector/src/**',
         'packages/client/modules/src/client/system.ts',
         'packages/client/hmr/src/client/index.ts',
         // Web config-tree boot round: the new host-side web-transport halves

+ 4 - 1
vitest.e2e.config.ts

@@ -43,7 +43,10 @@ export default defineConfig({
     // apps/cli only, not apps/*: apps/web/tests/*.e2e.ts needs the built
     // frontend dist and runs under vitest.web.config.ts (the test:web job).
     include: ['packages/*/*/tests/**/*.e2e.ts', 'apps/cli/tests/**/*.e2e.ts'],
-    exclude: ['**/*.expected.e2e.ts'],
+    exclude: [
+      '**/*.expected.e2e.ts',
+      'packages/experimental/inspector/tests/client-browser.e2e.ts',
+    ],
     // Real model calls: generous timeouts, and retries for transient flakes
     // (the shared internal key hits concurrency quotas). No coverage — the
     // unit suites own the coverage gate.

+ 1 - 0
vitest.web.config.ts

@@ -26,6 +26,7 @@ export default defineConfig({
     include: [
       'apps/web/tests/**/*.e2e.ts',
       'apps/web/tests/**/*.snapshot.ts',
+      'packages/experimental/inspector/tests/client-browser.e2e.ts',
     ],
     // Local and record runs stay serial. CI runs workspace-mutating HMR and
     // dynamic Cordis lifecycle coverage before parallelizing the remaining files.