mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
Merge pull request #3012 from deepseek-harness/worktree-inspectorcordis
feat(inspector): add cross-realm CDP inspection
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.md
|
||||
2026-08-23-cross-realm-cdp-inspector.md: e6e1d48da4f7b2dfab2772e24cb49711e3e50f5a
|
||||
2026-08-23-cross-realm-cdp-inspector.zh.md: 9d67868caf9b4e5e0eb06583facd608b71468b22
|
||||
@@ -0,0 +1,100 @@
|
||||
# Agent Note: Cross-realm CDP inspector
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-23-cross-realm-cdp-inspector.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Host diagnostics, browser Client observations, and JavaScript debugging originate in different JavaScript realms. A debugger transport implemented on the Host main thread cannot deliver `Debugger.resume` while that thread is paused, and a design that lets each producer emit CDP directly duplicates protocol state and couples application instrumentation to Chrome's presentation protocol.
|
||||
|
||||
## Decision
|
||||
|
||||
`@deepseek-ai/dsh-experimental-inspector` is one private Client/Host Cordis plugin package. Its Host face starts a Node Worker; its Client face connects directly to that Worker. Cordis owns composition, service publication, bootstrap injection, and disposal only. The source protocol, Worker state, CDP server, V8 bridge, and domain adapters do not inspect Cordis runtime data.
|
||||
|
||||
The Worker is the sole CDP endpoint and the sole owner of CDP state. Host and Client producers send validated observations under a versioned internal protocol; Client Runtime, Console, Sources, and semantic queries use separate typed frame families on the same authenticated carrier. A realm registry gives every DevTools connection the same Runtime, Console, Sources, and Debugger capability slots, while explicit unsupported members preserve different Host and Client support levels.
|
||||
|
||||
## Realm ownership
|
||||
|
||||
The Host main thread owns application objects and `globalThis.fetch`. It sends observations over a dedicated `MessagePort` and never constructs CDP messages.
|
||||
|
||||
The Client page owns browser observations, evaluated values, and Client object handles. It exchanges JSON frames directly with the Worker over an authenticated ingest WebSocket, so a paused Host does not stop Client delivery or Runtime execution.
|
||||
|
||||
The Inspector Worker owns HTTP discovery, both WebSocket routes, source generations, retention, realm sessions, CDP sessions, and domain adapters. Each DevTools connection opens one backend session for the Host and every connected Client realm. V8 object ids remain inside the Node Runtime backend. Client object handles remain inside the typed Client protocol. One connection-local object table maps either backend handle to CDP object ids and projects the same RemoteObject, property, exception, Console, and paused-frame types.
|
||||
|
||||
Chrome DevTools consumes one page-type target. Runtime methods route by execution context or object id. Debugger source methods route by script id; Host scripts retain native debugging, while Client scripts expose read-only content and reject active debugging. `Profiler` and `HeapProfiler` remain Host-only. `Network` and the minimal page-target scaffold run inside the Worker.
|
||||
|
||||
## Source protocol
|
||||
|
||||
Both MessagePort and WebSocket carriers use the same JSON value set and discriminated frames. A source identifies one logical producer and one connection generation, declares capabilities and topics, sends an initial replacement, then appends sequence-numbered batches. The Worker rejects malformed, oversized, stale-generation, and undeclared-topic frames before reading domain fields.
|
||||
|
||||
Delivery is ordered and best-effort. Producers never wait for an acknowledgement on an application path. A bounded producer queue reports dropped prefixes through sequence gaps; the Host MessagePort carrier permits one append batch in flight and sends the next after the Worker acknowledges consumption. The Worker requests a new snapshot after an unexplained gap. Domain stores retain bounded state and explicitly close unfinished operations when a source disconnects.
|
||||
|
||||
Runtime frames use closed command and result unions instead of method strings with untyped parameter records. Every request carries a source id, source generation, DevTools Runtime session id, request id, and command. Every result repeats those identities and the command discriminant. Console lifecycle/events, chunked source reads, and non-CDP semantic queries have separate correlated frame families. RemoteObject values, previews, property descriptors, call arguments, exceptions, Console events, debugger frames, scripts, and errors have dedicated exact decoders.
|
||||
|
||||
## Client Runtime, Console, and Sources
|
||||
|
||||
`Runtime.enable` publishes the Host's real execution context and one negative-id synthetic execution context for each connected Client source that declares the Runtime capability. An omitted context continues to mean Host. Client source replacement destroys the old context and creates a new context with a new generation and unique id.
|
||||
|
||||
The Client Runtime subset covers `Runtime.evaluate`, `Runtime.getProperties`, `Runtime.callFunctionOn`, `Runtime.awaitPromise`, `Runtime.releaseObject`, `Runtime.releaseObjectGroup`, and `Runtime.globalLexicalScopeNames`. The Client executes commands in its page realm and retains live objects in a table isolated by DevTools Runtime session. It returns opaque handles and JSON-safe metadata; the Worker validates the result and assigns a connection-local CDP object id. An object argument may be used only by the same Client source generation and DevTools session. Closing the source, disabling Runtime, closing DevTools, releasing an object, or releasing an object group removes the corresponding handles.
|
||||
|
||||
JavaScript exceptions are successful Runtime responses carrying `exceptionDetails`; transport failures use a separate error union. A Worker deadline sends request-scoped cancellation to the Client. Handles allocated for a response remain provisional until the Worker acknowledges that response, so cancellation and late responses cannot leave unreachable objects. Finite command deadlines, object counts, property counts, source bytes, and frame bytes bound retained or returned state.
|
||||
|
||||
The Client Console observer preserves the original page call and asynchronously emits one event per enabled DevTools session. Each session serializes arguments into its own `console` object group, so disconnect, Runtime disable, or `Runtime.discardConsoleEntries` can release one connection without invalidating another. Context and Fiber arguments use the same semantic reference and DOM reverse mapping as evaluation results.
|
||||
|
||||
The Client discovers this package's `lib/client.js` URL from the assembled web boot graph. `Debugger.enable` reads metadata through a typed source operation, and `Debugger.getScriptSource` reassembles bounded base64 chunks; the source map remains available at the advertised URL. Client-script breakpoint, step, and call-frame operations remain explicitly unsupported because page JavaScript cannot pause its own realm and continue servicing control messages. Target-wide pause and resume continue to control the Host debugger.
|
||||
|
||||
## Host debugging
|
||||
|
||||
The Worker attaches each DevTools connection to the Host main isolate through its own Node inspector Session. Node Runtime, Console, Sources, and Debugger backends normalize native values and events into the same realm model used by Client backends. The common projector allocates connection-local object ids for evaluation results, Console arguments, paused scopes, and call-frame results. Breakpoint requests are translated back to native backend handles before reaching Node. The default context may receive the display name `Host` while retaining its real id and metadata.
|
||||
|
||||
The Worker event loop, DevTools socket, Client ingest socket, and Node inspector Session remain runnable while Host JavaScript is paused. Host observations naturally stop until resume.
|
||||
|
||||
## Fetch capture
|
||||
|
||||
Fetch capture wraps `globalThis.fetch` and is enabled by default. Every later fetch records its complete URL, headers, request body, response headers, response body, timing, cancellation, and error. No field is redacted by default; using the inspector grants local DevTools access to those secrets.
|
||||
|
||||
The wrapper passes a normalized Request to the original fetch, reads request and response clones on independent capture tasks, and returns the original Response as soon as fetch resolves. Capture failure never changes the caller's fetch result. Finite per-body and journal budgets prevent unbounded retention; exceeding a budget preserves the captured prefix and reports truncation.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Run the CDP server on the Host main thread.** Rejected because a breakpoint freezes the socket responsible for delivering `Debugger.resume`.
|
||||
|
||||
**Relay Client observations through the Host web server.** Rejected because the relay also freezes at a Host breakpoint and makes the Client data path depend on Host responsiveness.
|
||||
|
||||
**Let producers emit CDP messages.** Rejected because producer code would own Chrome-specific request ids, replay, enable state, and ordering instead of domain observations.
|
||||
|
||||
**Share one Node inspector Session across DevTools clients.** Rejected because object ids, object groups, enable state, and debugger operations belong to one protocol session; sharing requires an error-prone virtual-session layer.
|
||||
|
||||
**Send live Client objects or CDP object ids over WebSocket.** Rejected because JSON cannot preserve identity or behavior, and a CDP object id belongs to one DevTools session. Client-local handles plus a Worker-owned per-connection mapping preserve both ownership rules.
|
||||
|
||||
**Use one untyped Runtime RPC method.** Rejected because method strings and arbitrary parameter objects cannot enforce command/result correlation, object-reference ownership, or exhaustive evolution as Runtime, Sources, and Debugger support grows.
|
||||
|
||||
**Split protocol, Host, and Client into separate packages.** Rejected for the experimental phase. One package keeps the capability deployable as one Client/Host plugin while source directories and build entries preserve realm boundaries.
|
||||
|
||||
**Use Undici diagnostics channels as the complete fetch source.** Rejected because they observe transport lifecycle but cannot provide complete request and response bodies without consuming application streams. They may later augment transport-level timing.
|
||||
|
||||
## Verification
|
||||
|
||||
- A real Worker accepts Host MessagePort and Client WebSocket sources and exposes both through one CDP target.
|
||||
- Malformed, oversized, stale-generation, and sequence-gap frames cannot corrupt another source or the Worker.
|
||||
- Console evaluates in the Host context and receives Host console events.
|
||||
- Console lists Host and Client contexts; Client evaluation, properties, function calls, promise awaiting, and release operations preserve RemoteObject identity without sharing objects across realms or DevTools connections.
|
||||
- Host and Client Console events use the same projector; Client arguments remain isolated by DevTools connection and Cordis arguments resolve to Elements nodes.
|
||||
- Sources receives Host scripts and the built Client bundle; Client source reads are chunked and active debugging fails explicitly, while a breakpoint can pause the Host, evaluate a call frame, and resume.
|
||||
- Host paused scopes and call-frame results use the same connection-local RemoteObject table as Runtime evaluation.
|
||||
- Network replays requests that predate `Network.enable` and streams later requests without loss or duplication.
|
||||
- Successful, failed, aborted, redirected, textual, binary, streaming, and truncated fetches preserve caller behavior and expose the configured captured data.
|
||||
- Disposal stops capture, closes admission, disconnects V8 sessions, closes sockets, and waits for Worker exit before completing.
|
||||
|
||||
## Consequences
|
||||
|
||||
The Worker-owned endpoint keeps DevTools control responsive while Host JavaScript is paused and gives Host and Client observations one CDP state owner. That ownership adds the following security, resource, and compatibility costs.
|
||||
|
||||
Full fetch capture intentionally exposes credentials and payloads to any local process that can attach to the CDP endpoint. Loopback binding is mandatory but is not authentication.
|
||||
|
||||
Cloning request and response streams adds CPU, memory, and I/O pressure. Finite limits bound retained bytes but cannot make full capture free.
|
||||
|
||||
A page-type synthetic target depends on a small set of Chrome DevTools compatibility responses outside Node's native inspector domains. Each no-op must be named and covered because silently accepting every unknown method hides protocol drift.
|
||||
|
||||
Client Runtime execution uses page JavaScript evaluation, so page Content Security Policy may reject it and native DevTools command-line or REPL semantics are not promised. Read-only Client Sources do not imply active Client debugging; adding that capability requires an execution agent that remains responsive while the inspected page realm is paused.
|
||||
@@ -0,0 +1,100 @@
|
||||
# Agent Note: 跨 realm CDP Inspector
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-23-cross-realm-cdp-inspector.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
Host 诊断、浏览器 Client 观测和 JavaScript 调试来自不同 JavaScript realm。Host 主线程上的 debugger transport 无法在该线程暂停时投递 `Debugger.resume`;若每个 producer 直接生成 CDP,又会重复协议状态,并把应用观测逻辑绑到 Chrome 呈现协议。
|
||||
|
||||
## Decision
|
||||
|
||||
`@deepseek-ai/dsh-experimental-inspector` 是一个私有 Client/Host 双面 Cordis 插件包。Host 面启动 Node Worker,Client 面直接连接该 Worker。Cordis 只负责组合、服务发布、bootstrap 注入与 dispose;source 协议、Worker 状态、CDP server、V8 bridge 和 domain adapter 不检查 Cordis 运行时数据。
|
||||
|
||||
Worker 是唯一 CDP endpoint,也是 CDP 状态的唯一 owner。Host 与 Client producer 通过有版本的内部协议发送验证后的观测记录;Client Runtime、Console、Sources 和语义查询在同一条鉴权 carrier 上使用相互独立的类型化帧。realm registry 为每条 DevTools 连接提供相同的 Runtime、Console、Sources 和 Debugger capability slot,并用明确的 unsupported 成员保留 Host 与 Client 的支持差异。
|
||||
|
||||
## Realm 所有权
|
||||
|
||||
Host 主线程拥有应用对象和 `globalThis.fetch`。它通过专用 `MessagePort` 发送观测记录,绝不构造 CDP 消息。
|
||||
|
||||
Client 页面拥有浏览器观测、求值得到的值和 Client object handle。它通过带鉴权的 ingest WebSocket 直接与 Worker 交换 JSON 帧,因此 Host 暂停不会阻断 Client 投递或 Runtime 执行。
|
||||
|
||||
Inspector Worker 拥有 HTTP discovery、两条 WebSocket route、source generation、保留历史、realm session、CDP session 和 domain adapter。每条 DevTools 连接为 Host 和每个已连接 Client realm 分别建立一套 backend session。V8 object id 只留在 Node Runtime backend 内。Client object handle 只留在类型化 Client 协议内。单个 connection-local object table 把两类 backend handle 映射成 CDP object id,并投影同一种 RemoteObject、property、exception、Console 与 paused-frame 类型。
|
||||
|
||||
Chrome DevTools 消费一个 page 类型 target。Runtime 方法按 execution context 或 object id 路由;Debugger source 方法按 script id 路由。Host script 保留原生调试,Client script 只暴露只读内容,并拒绝 active debugging。`Profiler` 与 `HeapProfiler` 仍然只属于 Host;`Network` 与最小 page-target scaffold 在 Worker 内执行。
|
||||
|
||||
## Source 协议
|
||||
|
||||
MessagePort 与 WebSocket carrier 使用同一组 JSON 值和判别联合帧。source 标识一个逻辑 producer 和一个连接 generation,声明 capability 与 topic,发送初始 replace,再追加带 sequence 的 batch。Worker 在读取 domain 字段前拒绝畸形、超限、旧 generation 和未声明 topic 的帧。
|
||||
|
||||
投递有序且尽力而为。producer 不在应用路径上等待 acknowledgement。有界 producer 队列通过 sequence gap 报告被丢弃的前缀;Host MessagePort carrier 同时只允许一个 append batch 在途,并在 Worker 确认消费后发送下一批。无法解释的 gap 会让 Worker 请求新 snapshot。domain store 只保留有界状态,并在 source 断开时明确关闭未完成操作。
|
||||
|
||||
Runtime 帧使用封闭的 command 与 result 联合,而不是 method 字符串加无类型 parameter record。每个 request 携带 source id、source generation、DevTools Runtime session id、request id 和 command;每个 result 重复这些身份与 command 判别符。Console lifecycle/event、分块 source 读取和非 CDP 语义查询使用各自独立的关联帧。RemoteObject value、preview、property descriptor、call argument、exception、Console event、debugger frame、script 与 error 都有独立的精确 decoder。
|
||||
|
||||
## Client Runtime、Console 与 Sources
|
||||
|
||||
`Runtime.enable` 发布 Host 的真实 execution context,并为每个声明 Runtime 能力的已连接 Client source 发布一个负数 id synthetic execution context。不指定 context 仍然表示 Host。Client source replacement 会销毁旧 context,并以新的 generation 与 unique id 创建新 context。
|
||||
|
||||
Client Runtime 子集包括 `Runtime.evaluate`、`Runtime.getProperties`、`Runtime.callFunctionOn`、`Runtime.awaitPromise`、`Runtime.releaseObject`、`Runtime.releaseObjectGroup` 和 `Runtime.globalLexicalScopeNames`。Client 在页面 realm 中执行命令,并在按 DevTools Runtime session 隔离的表中保留实时对象。Client 只返回不透明 handle 与 JSON-safe metadata;Worker 验证结果并分配连接私有的 CDP object id。对象参数只能由同一 Client source generation 与 DevTools session 使用。source 断开、Runtime disable、DevTools 关闭、释放对象或释放 object group 都会移除对应 handle。
|
||||
|
||||
JavaScript exception 是携带 `exceptionDetails` 的成功 Runtime response;transport failure 使用独立的 error 联合。Worker deadline 会向 Client 发送 request-scoped cancellation。response 分配的 handle 在 Worker 确认该 response 前保持 provisional,因此 cancellation 和 late response 不会留下无法访问的对象。有限的命令 deadline、对象数、属性数、source 字节数与帧字节数约束保留或返回的状态。
|
||||
|
||||
Client Console observer 保持原始页面调用行为,并为每个已启用的 DevTools session 异步发出一份 event。每个 session 把 argument 序列化到自己的 `console` object group,因此断联、Runtime disable 或 `Runtime.discardConsoleEntries` 可以释放一条连接而不使其他连接失效。Context 与 Fiber argument 使用和求值结果相同的语义引用及 DOM 反向映射。
|
||||
|
||||
Client 从组装后的 web boot graph 发现本包 `lib/client.js` 的 URL。`Debugger.enable` 通过类型化 source operation 读取 metadata,`Debugger.getScriptSource` 重组有界 base64 chunk;source map 保持在公布的 URL 上可用。Client script breakpoint、step 与 call-frame 操作明确不受支持,因为页面 JavaScript 无法暂停自身 realm 后继续处理控制消息。target-wide pause 与 resume 继续控制 Host debugger。
|
||||
|
||||
## Host 调试
|
||||
|
||||
Worker 为每条 DevTools 连接建立独立 Node inspector Session,并连接 Host 主 isolate。Node Runtime、Console、Sources 与 Debugger backend 把原生 value 和 event 归一化成 Client backend 使用的同一种 realm model。公共 projector 为求值结果、Console argument、paused scope 和 call-frame result 分配 connection-local object id。breakpoint request 到达 Node 前会反向转换成原生 backend handle。默认 context 可以改显示名为 `Host`,但保留真实 id 和 metadata。
|
||||
|
||||
Host JavaScript 暂停时,Worker event loop、DevTools socket、Client ingest socket 与 Node inspector Session 仍可运行。Host 观测自然暂停到 resume。
|
||||
|
||||
## Fetch 采集
|
||||
|
||||
fetch 采集包装 `globalThis.fetch`,并默认开启。之后每次 fetch 都记录完整 URL、headers、请求体、响应 headers、响应体、时间、取消与错误。默认不脱敏任何字段;启用 Inspector 即把这些秘密交给本机 DevTools。
|
||||
|
||||
wrapper 把标准化 Request 交给原 fetch,通过独立采集任务读取 request/response clone,并在 fetch resolve 后立即把原始 Response 交给调用方。采集失败不得改变调用方的 fetch 结果。有限的单体与 journal 预算阻止无界保留;超过预算时保留已采集前缀并报告截断。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**在 Host 主线程运行 CDP server。** 拒绝,因为断点会冻结负责投递 `Debugger.resume` 的 socket。
|
||||
|
||||
**经 Host web server 中转 Client 观测。** 拒绝,因为 Host 断点同样冻结中转,并使 Client 数据路径依赖 Host 响应。
|
||||
|
||||
**让 producer 直接生成 CDP 消息。** 拒绝,因为 Chrome 专用 request id、回放、enable 状态和排序会落入 producer,而不是领域观测。
|
||||
|
||||
**多个 DevTools client 共用一个 Node inspector Session。** 拒绝,因为 object id、object group、enable 状态与 debugger 操作属于单个协议 session;共享需要易错的虚拟 session 层。
|
||||
|
||||
**通过 WebSocket 发送 Client 实时对象或 CDP object id。** 拒绝,因为 JSON 无法保留对象身份或行为,而 CDP object id 只属于一条 DevTools session。Client-local handle 加 Worker 所有的逐连接映射同时维护这两条所有权规则。
|
||||
|
||||
**使用一个无类型 Runtime RPC method。** 拒绝,因为 method 字符串和任意 parameter object 无法保证 command/result 关联、对象引用所有权,也无法在 Runtime、Sources 与 Debugger 支持增长时做穷尽演进。
|
||||
|
||||
**把 protocol、Host 与 Client 拆成多个包。** 实验阶段拒绝。一个包保持能力以一个 Client/Host 插件部署,同时由源码目录与构建入口维护 realm 边界。
|
||||
|
||||
**用 Undici diagnostics channel 作为完整 fetch 数据源。** 拒绝,因为它能观察 transport lifecycle,却无法在不消费应用 stream 的前提下提供完整 request/response body。后续可以用它补充 transport 级 timing。
|
||||
|
||||
## Verification
|
||||
|
||||
- 真实 Worker 同时接收 Host MessagePort 与 Client WebSocket source,并通过一个 CDP target 暴露两者。
|
||||
- 畸形、超限、旧 generation 与 sequence gap 帧不会破坏其他 source 或 Worker。
|
||||
- Console 在 Host context 求值并接收 Host console event。
|
||||
- Console 列出 Host 与 Client context;Client 求值、属性、函数调用、Promise await 与释放操作维持 RemoteObject 身份,且不在 realm 或 DevTools 连接之间共享对象。
|
||||
- Host 与 Client Console event 使用相同 projector;Client argument 按 DevTools 连接隔离,Cordis argument 可以解析到 Elements node。
|
||||
- Sources 接收 Host script 与构建后的 Client bundle;Client source 读取采用分块传输,active debugging 明确失败,而 Host 仍可被断点暂停、求值 call frame 并 resume。
|
||||
- Host paused scope 与 call-frame result 使用和 Runtime 求值相同的 connection-local RemoteObject table。
|
||||
- Network 回放 `Network.enable` 前的请求,并无遗漏、无重复地推送后续请求。
|
||||
- 成功、失败、取消、重定向、文本、二进制、流式与截断 fetch 都保持调用方行为,并暴露配置允许的完整采集数据。
|
||||
- dispose 停止采集、关闭入口、断开 V8 session、关闭 socket,并等待 Worker exit 后完成。
|
||||
|
||||
## Consequences
|
||||
|
||||
Worker 所有的 endpoint 在 Host JavaScript 暂停时仍保持 DevTools 控制可响应,并让 Host 与 Client 观测共享唯一 CDP 状态 owner。这项所有权带来以下安全、资源与兼容性成本。
|
||||
|
||||
完整 fetch 采集会有意把 credential 和 payload 暴露给任何能连接 CDP endpoint 的本机进程。loopback 监听是强制要求,但不是鉴权。
|
||||
|
||||
clone request/response stream 会增加 CPU、内存与 I/O 压力。有限预算能约束保留字节,不能让完整采集没有成本。
|
||||
|
||||
page 类型 synthetic target 依赖 Node 原生 inspector domain 之外的一组 Chrome DevTools 兼容响应。每个 no-op 都必须明确命名并有测试;统一吞掉未知方法会掩盖协议漂移。
|
||||
|
||||
Client Runtime 执行使用页面 JavaScript 求值,因此页面 Content Security Policy 可能拒绝它,也不承诺原生 DevTools command-line 或 REPL 语义。只读 Client Sources 不代表 active Client debugging;增加该能力需要一个在被检查页面 realm 暂停时仍能响应的执行 agent。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.md
|
||||
2026-08-24-cordis-runtime-tree-inspection.md: 9784018a050bdb27e791a44e1cd342a31cec42c0
|
||||
2026-08-24-cordis-runtime-tree-inspection.zh.md: 05b9b0d4387447b7c8df05b7c7e771e96c767bd9
|
||||
@@ -0,0 +1,113 @@
|
||||
# Agent Note: Cordis runtime tree inspection
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-24-cordis-runtime-tree-inspection.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The Inspector needs to present each Host and Client Cordis runtime as a tree in Chrome DevTools Elements. A Cordis Context or Fiber selected in Elements must also behave as a live Runtime object, while a Cordis object printed in Console must be revealable as the same semantic node. CDP identifiers cannot be the source model: `NodeId`, `BackendNodeId`, and `RemoteObjectId` have different owners and lifetimes, and a future model-facing runtime query must consume the same Cordis data without translating CDP.
|
||||
|
||||
Host and Client run the same Cordis abstractions in different JavaScript realms. Tree discovery and classification must therefore be one browser-safe implementation, while object resolution remains realm-local and only opaque references cross MessagePort or WebSocket boundaries.
|
||||
|
||||
## Decision
|
||||
|
||||
The Inspector uses one serialized Cordis tree model and keeps CDP as one adapter over it. The package separates live-object discovery, immutable snapshots, Worker-owned storage, and consumers:
|
||||
|
||||
The existing [cross-realm Inspector decision](../../implemented/architecture/2026-08-23-cross-realm-cdp-inspector.md) owns the Worker, source carriers, Runtime routing, and security model. This note owns only the Cordis semantic data and its consumers.
|
||||
|
||||
```text
|
||||
Host Context/Fiber ─┐
|
||||
├─ CordisTreeCollector ─ CordisTreeSnapshot ─ source transport ─ CordisTreeStore ─┬─ CDP DOM adapter
|
||||
Client Context/Fiber┘ └─ future model adapter
|
||||
```
|
||||
|
||||
`CordisTreeCollector` and its identity registry are browser-safe modules compiled into both package faces. Host and Client instantiate that same code against their own `ctx.root`; neither side carries a second classification implementation.
|
||||
|
||||
## Cordis tree model
|
||||
|
||||
`CordisTreeSnapshot` is a CDP-independent, lossless-JSON value with a schema version, monotonically increasing revision, object-registry id, truncation flag, and one nested root Context. Context nodes contain an opaque object handle and ordered Context/Fiber children. Fiber nodes contain their Cordis `uid`, an opaque object handle, and exactly one Context child representing `fiber.ctx`. Host and Client publish this same realm-tree type. No generated Context id, plugin metadata, service data, arbitrary property value, or object preview enters the tree.
|
||||
|
||||
The inspection tree starts at the root Context and omits the Cordis root Fiber. For every other plugin, its parent Context contains the Fiber and that Fiber contains its owned Context. A Context created by `extend()`, `isolate()`, or `intercept()` without a new Fiber remains a direct Context child. Nesting expresses parentage without generated node ids and preserves both object identities without introducing the `Fiber.ctx` / `Context.fiber` cycle.
|
||||
|
||||
The collector starts from the root, every live registry Fiber, and every event hook's owning Context. It follows Context prototype links back to the inspected root, unwraps Cordis shadow contexts, deduplicates by object identity, and excludes disposed fibers. `internal/plugin` and `internal/status` events schedule one microtask-coalesced replacement snapshot. Node-count and encoded-byte limits remove complete trailing branches, so every retained node still has its parent and every retained Fiber still has its owned Context.
|
||||
|
||||
## Identity and lifetime
|
||||
|
||||
The identities are intentionally distinct:
|
||||
|
||||
- Fiber `uid` comes from Cordis. Context currently has no Cordis-owned id and the Inspector does not expose a generated substitute.
|
||||
- `InspectorObjectReference` is an opaque realm-local handle resolving a tree node to its live Context or Fiber. Snapshots carry the handle for routing, never as a semantic id or DOM attribute.
|
||||
- `BackendNodeId` is assigned by the Worker to one retained `(source id, source generation, object reference)` and is shared by DevTools connections while that generation's snapshot is retained.
|
||||
- `NodeId` is assigned per DevTools connection when a node enters that frontend's document. It remains stable while the corresponding backend node is retained and is discarded when that node leaves the tree, on the rare full-document fallback, or when the connection closes.
|
||||
- `RemoteObjectId` is assigned by the selected Runtime session when `DOM.resolveNode` exposes the live object. It remains scoped to that DevTools connection and object group.
|
||||
|
||||
`sourceId` identifies one Client runtime instance and remains stable across its automatic transport reconnects; `generation` identifies one WebSocket admission. Disconnect removes the synthetic context from the Console with `Runtime.executionContextDestroyed`. Reconnection announces a fresh CDP execution-context id because the destroyed id and its RemoteObjects cannot be reused, but this does not imply that the browser's underlying JavaScript realm was recreated.
|
||||
|
||||
Standard CDP does not place a `RemoteObjectId` field on `DOM.Node`. `DOM.Node` carries `nodeId` and `backendNodeId`; `DOM.resolveNode` returns the corresponding `Runtime.RemoteObject`, and `DOM.requestNode` performs the reverse mapping. The implementation keeps these three CDP identities correlated without adding non-standard DOM fields.
|
||||
|
||||
## Realm object bridge
|
||||
|
||||
Each collector registers a realm-local object table under a private global symbol. The table maps opaque handles to live objects and can identify a currently retained object by identity. Replacing a snapshot removes handles absent from the new tree; disposing the observer unregisters the table.
|
||||
|
||||
For Host nodes, the Worker uses that DevTools connection's private `node:inspector.Session` to evaluate a lookup in the Host table, producing a native V8 `RemoteObjectId`. For Client nodes, the Worker routes the same lookup through the existing typed Client Runtime channel and maps the returned Client handle to a connection-local CDP object id. No live object or engine object id crosses a source transport.
|
||||
|
||||
Client Runtime values carry an optional validated `InspectorObjectReference`, while Host Runtime values are probed through their native V8 object id. The common CDP adapter changes recognized evaluation results, properties, exceptions, Console arguments, and paused-frame objects to `subtype: "node"`, records the object-id-to-backend-node relation, and supplies the Cordis element description. This gives both directions: Elements can expose a live object, and a Context or Fiber returned or printed in Console can be revealed in Elements.
|
||||
|
||||
## Worker repository and updates
|
||||
|
||||
Sources publish the Cordis tree as retained state rather than an event history. Host MessagePort and Client WebSocket publishers keep the latest state record and include it in `source/replace` after admission, reconnection, or a resnapshot request. Live replacements still use the ordinary sequenced append path. The Worker validates every snapshot for exact fields, bounded node count and depth, unique object handles and Fiber uids, a Context root, and exactly one Context child per Fiber before atomically replacing the prior tree.
|
||||
|
||||
`CordisTreeStore` owns validated realm snapshots and source lifecycle only. Its internal reader retains live object routes for Runtime and DOM, while its public reader projects a detached `{ host, clients }` tree without transport or CDP ids. Host and Client `ctx.inspector.cordis.getTree()` calls use the same correlated query protocol and Worker reader without creating a CDP session. `CordisDomBackend` adds Worker-global backend ids, while each `CordisDomSession` owns frontend node ids, searches, enabled state, and RemoteObject correlations. A model adapter can consume the public reader without depending on DOM serialization or debugger activation.
|
||||
|
||||
Closing a source changes its stored tree from connected to disconnected instead of deleting the last snapshot. Object lookup excludes disconnected trees, so the snapshot remains inspectable as data without retaining or reviving a live Context, Fiber, or Runtime object. A replacement from the same source id and a new transport generation atomically restores the connected state. The configurable disconnected-tree limit evicts the oldest retained snapshots.
|
||||
|
||||
Accepted source snapshots rebuild the connection-neutral document and are diffed by stable backend node identity. A revision-only replacement emits no DOM event. Child insertion and removal use `DOM.childNodeInserted` and `DOM.childNodeRemoved`; attribute changes use their corresponding DOM events; sibling reorder falls back to `DOM.setChildNodes` for that parent only. Reusing one backend identity for a different node kind is the sole `DOM.documentUpdated` fallback. A disconnect invalidates object routes without changing the retained DOM tree, preserving expansion and selection; retention eviction removes only the evicted `<client>` node.
|
||||
|
||||
## CDP projection
|
||||
|
||||
The synthetic document has a `<host>` container and a `<clients>` container. `<host>` contains the Host root Context. `<clients>` contains one `<client>` per Client source, and each `<client>` contains that realm's root Context. These structural elements have no Runtime object or attributes. Context elements have no attributes. Fiber elements expose only `uid`, copied without reinterpretation from Cordis. Connected Context and Fiber elements resolve to live RemoteObjects; disconnected snapshots retain their DOM nodes but object resolution fails.
|
||||
|
||||
Standard CDP has no backend-controlled frozen, locked, or dimmed state for a node in the ordinary Elements tree. Chromium's detached-node presentation is frontend-local to the Memory panel's `DOM.getDetachedDomNodes` flow. No connection-state attribute or non-standard `DOM.Node` field is added until its presentation is decided.
|
||||
|
||||
The read-only adapter implements document retrieval, child requests, node description, attributes, outer HTML, search, backend-id pushes, node resolution, and reverse object lookup. Mutating DOM methods fail explicitly. Layout, CSS, accessibility, and browser DOM geometry are outside this semantic tree and return empty or unsupported responses only where Chrome DevTools requires a compatibility response.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Build CDP DOM nodes directly in each realm.** Rejected because Host and Client would duplicate classification, frontend ids would leak into source protocols, and a model consumer would need to reverse a presentation protocol back into Cordis concepts.
|
||||
|
||||
**Send live objects or V8 object ids to the Worker.** Rejected because structured clone and JSON do not preserve identity or behavior, and engine object ids belong to one inspector session.
|
||||
|
||||
**Generate an Inspector Context id.** Rejected because Cordis Context has no intrinsic id and a presentation adapter must not make an implementation key look like framework identity. Nested children express parentage; opaque object handles remain routing data.
|
||||
|
||||
**Use one id for Fiber uid, backend nodes, and frontend nodes.** Rejected because source reconnection, multiple DevTools connections, document refresh, and Runtime object release have independent lifetimes.
|
||||
|
||||
**Expose only Contexts and treat each Fiber-owned Context as the Fiber.** Rejected because it loses one of the two live objects, makes Console identity ambiguous, and prevents later Fiber-specific properties from having a stable owner.
|
||||
|
||||
**Put the model-facing API on the CDP adapter.** Rejected because model access would inherit Chrome-specific node serialization, per-connection ids, and enable state. The Worker repository is the shared source; CDP and model access are sibling adapters.
|
||||
|
||||
**Remove a realm tree when its source disconnects.** Rejected because transport loss would discard the last useful topology and collapse the user's Elements inspection state. Keeping old object handles usable was also rejected: a new connection generation cannot prove that any prior live object still exists.
|
||||
|
||||
## Verification
|
||||
|
||||
- The same collector implementation produces Host and Client snapshots from equivalent Cordis runtimes.
|
||||
- Elements shows `<host>` and `<clients>/<client>` containers with each realm's root Context directly beneath its container.
|
||||
- Context elements have no attributes; Fiber elements expose only their Cordis `uid`; the root Fiber is absent.
|
||||
- Every connected Context and Fiber has a connection-local frontend node id, a Worker backend node id, and a resolvable connection-local Runtime object id without exposing them as attributes.
|
||||
- `DOM.resolveNode` and `DOM.requestNode` round-trip Context and Fiber identities without sharing object ids across DevTools connections or source generations.
|
||||
- A Context or Fiber returned by Runtime evaluation is node-branded and can be revealed in Elements.
|
||||
- Disconnect destroys the Client execution context and its RemoteObjects while retaining the last Elements tree unchanged; a new transport generation replaces it after a complete snapshot arrives.
|
||||
- Reconnect and resnapshot replay the latest tree state; unchanged snapshots emit no DOM mutation, while structural changes update only their affected parent or node. Malformed or oversized replacements do not replace the last valid snapshot.
|
||||
- The stored snapshot and query API contain no CDP types and can support a future model-facing adapter unchanged.
|
||||
|
||||
## Consequences
|
||||
|
||||
Cordis exposes no complete global Context registry. The collector can recover contexts reachable from live fibers and event hooks; a context that is created, never used, and retained only by application code is intentionally absent.
|
||||
|
||||
Object recognition adds a Runtime round trip for each Host object that requires semantic identification. An annotation failure leaves an ordinary RemoteObject rather than breaking Runtime or Debugger delivery. Client Console observation preserves the original method result and schedules serialization afterward; each enabled DevTools session receives independently retained handles, so recognition never blocks the page call or shares objects between connections.
|
||||
|
||||
Sources continue to publish complete snapshots, keeping one shared Host/Client collector and allowing recovery after dropped observations. The Worker pays the snapshot comparison cost, then emits incremental CDP DOM mutations so unchanged revisions do not reset the Elements document. Node and byte limits preserve a valid prefix and report truncation; a later source delta protocol can replace the transport without changing the snapshot model or CDP projection.
|
||||
|
||||
The object table intentionally keeps every object in the current visible tree strongly reachable until the next replacement or observer disposal. This is bounded by the retained snapshot and must not become a general-purpose object registry.
|
||||
|
||||
The Worker retains only serialized metadata for a disconnected snapshot; any still-running source owns its realm-local object registry independently and disposal releases that registry. `maxDisconnectedCordisTrees` bounds Worker snapshot memory, and eviction removes the corresponding retained Client subtree.
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
# Agent Note: Cordis 运行时树检查
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-24-cordis-runtime-tree-inspection.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
Inspector 需要在 Chrome DevTools Elements 中把每个 Host 和 Client Cordis 运行时呈现为一棵树。在 Elements 中选中的 Cordis Context 或 Fiber 也必须表现为实时 Runtime 对象,而 Console 中打印出的 Cordis 对象必须能定位回同一个语义节点。CDP id 不能成为源模型:`NodeId`、`BackendNodeId` 与 `RemoteObjectId` 的 owner 和生命周期不同,并且未来面向模型的运行时查询必须使用同一份 Cordis 数据,而不是再反向解析 CDP。
|
||||
|
||||
Host 与 Client 在不同 JavaScript realm 中运行相同的 Cordis 抽象。因此,树发现与分类必须只有一份浏览器安全实现;对象解析仍留在各自 realm 内,跨 MessagePort 或 WebSocket 只传递不透明引用。
|
||||
|
||||
## Decision
|
||||
|
||||
Inspector 使用一套序列化 Cordis 树模型,并把 CDP 作为它的一个适配器。包内分离实时对象发现、不可变快照、Worker 存储与消费方:
|
||||
|
||||
现有的[跨 realm Inspector 决策](../../implemented/architecture/2026-08-23-cross-realm-cdp-inspector.zh.md)负责 Worker、source carrier、Runtime 路由与安全模型;本 Note 只负责 Cordis 语义数据及其消费方。
|
||||
|
||||
```text
|
||||
Host Context/Fiber ─┐
|
||||
├─ CordisTreeCollector ─ CordisTreeSnapshot ─ source transport ─ CordisTreeStore ─┬─ CDP DOM adapter
|
||||
Client Context/Fiber┘ └─ future model adapter
|
||||
```
|
||||
|
||||
`CordisTreeCollector` 及其身份注册表是浏览器安全模块,同时编入包的两个运行面。Host 与 Client 针对各自的 `ctx.root` 实例化同一份代码;任何一侧都不维护第二套分类实现。
|
||||
|
||||
## Cordis tree model
|
||||
|
||||
`CordisTreeSnapshot` 是与 CDP 无关的无损 JSON 值,包含 schema 版本、单调递增 revision、对象注册表 id、截断标志和一棵以 Context 为根的嵌套树。Context 节点包含不透明 object handle 与有序的 Context/Fiber children。Fiber 节点包含 Cordis `uid`、不透明 object handle,以及唯一一个表示 `fiber.ctx` 的 Context child。Host 与 Client 发布同一种 realm-tree 类型。生成的 Context id、插件 metadata、服务数据、任意属性值与对象 preview 都不进入树。
|
||||
|
||||
inspection tree 从 root Context 开始,不包含 Cordis root Fiber。对其他每个插件,其 parent Context 包含 Fiber,该 Fiber 再包含它拥有的 Context。通过 `extend()`、`isolate()` 或 `intercept()` 创建且未创建新 Fiber 的 Context 仍是直接 Context 子节点。嵌套结构无需生成 node id 即可表达 parent,并保留两类对象身份,同时避免把 `Fiber.ctx` / `Context.fiber` 环写入序列化树。
|
||||
|
||||
collector 从 root、注册表中的每个 live Fiber,以及每个 event hook 的 owner Context 开始。它沿 Context prototype 链回溯到被检查的 root,解开 Cordis shadow Context,按对象身份去重,并排除已 dispose 的 Fiber。`internal/plugin` 与 `internal/status` 事件调度一次 microtask 合并后的 replacement snapshot。节点数和编码字节数限制会移除完整的尾部 branch,因此每个保留节点仍有 parent,每个保留 Fiber 仍有其 owned Context。
|
||||
|
||||
## Identity and lifetime
|
||||
|
||||
各类身份刻意保持独立:
|
||||
|
||||
- Fiber `uid` 来自 Cordis。Context 当前没有 Cordis 自有 id,Inspector 不会暴露一个生成值来替代。
|
||||
- `InspectorObjectReference` 是 realm 本地的不透明 handle,用于把树节点解析成实时 Context 或 Fiber。snapshot 携带该 handle 只为完成路由,不把它当成语义 id 或 DOM attribute。
|
||||
- `BackendNodeId` 由 Worker 为一条保留的 `(source id, source generation, object reference)` 分配,并在该 generation 的 snapshot 被保留期间由所有 DevTools 连接共享。
|
||||
- `NodeId` 在节点进入某个 frontend document 时按 DevTools 连接分配;对应 backend node 被保留期间保持稳定,并在节点离开树、少见的整 document fallback 或连接关闭时丢弃。
|
||||
- `RemoteObjectId` 在 `DOM.resolveNode` 暴露实时对象时由选定的 Runtime session 分配;它只属于该 DevTools 连接和 object group。
|
||||
|
||||
`sourceId` 标识一个 Client runtime instance,并在自动重连 transport 时保持稳定;`generation` 标识一次 WebSocket 接纳。断联通过 `Runtime.executionContextDestroyed` 从 Console 移除 synthetic context。重连会发布新的 CDP execution-context id,因为已销毁的 id 及其 RemoteObject 不能复用;这并不表示浏览器底层 JavaScript realm 被重新创建。
|
||||
|
||||
标准 CDP 不会在 `DOM.Node` 上放置 `RemoteObjectId` 字段。`DOM.Node` 携带 `nodeId` 与 `backendNodeId`;`DOM.resolveNode` 返回对应的 `Runtime.RemoteObject`,`DOM.requestNode` 执行反向映射。实现会关联这三类 CDP 身份,而不添加非标准 DOM 字段。
|
||||
|
||||
## Realm object bridge
|
||||
|
||||
每个 collector 都在私有 global symbol 下注册一个 realm 本地对象表。该表把不透明 handle 映射到实时对象,并能按身份识别当前保留的对象。替换快照时会移除新树中不存在的 handle;dispose observer 时注销该表。
|
||||
|
||||
对 Host 节点,Worker 使用该 DevTools 连接私有的 `node:inspector.Session` 在 Host 对象表中执行查询,从而生成原生 V8 `RemoteObjectId`。对 Client 节点,Worker 通过已有的类型化 Client Runtime channel 路由同一查询,再把返回的 Client handle 映射为连接本地 CDP object id。实时对象和引擎 object id 都不会穿过 source transport。
|
||||
|
||||
Client Runtime value 携带一个可选、已验证的 `InspectorObjectReference`,Host Runtime value 则通过原生 V8 object id 探测。公共 CDP adapter 把已识别的 evaluation result、property、exception、Console argument 和 paused-frame object 改成 `subtype: "node"`,记录 object-id 到 backend-node 的关系,并提供 Cordis element description。这样两个方向都成立:Elements 可以暴露实时对象,Console 中返回或打印的 Context 与 Fiber 也能定位到 Elements。
|
||||
|
||||
## Worker repository and updates
|
||||
|
||||
source 把 Cordis 树作为保留状态发布,而不是事件历史。Host MessagePort 与 Client WebSocket publisher 保留最新状态记录,并在接纳、重连或收到 resnapshot 请求后把它放入 `source/replace`。实时 replacement 仍走普通的有序 append 路径。Worker 在原子替换旧树前验证 snapshot 的精确字段、节点数与深度限制、object handle 与 Fiber uid 唯一性、Context root,以及每个 Fiber 恰好拥有一个 Context child。
|
||||
|
||||
`CordisTreeStore` 只拥有已验证 realm snapshot 和 source 生命周期。内部 reader 为 Runtime 与 DOM 保留 live object route;公共 reader 则投影一棵不含 transport 或 CDP id 的 detached `{ host, clients }` tree。Host 与 Client 的 `ctx.inspector.cordis.getTree()` 通过同一套关联查询协议读取同一个 Worker reader,不创建 CDP session。`CordisDomBackend` 增加 Worker 全局 backend id,每个 `CordisDomSession` 则拥有 frontend node id、搜索、enable 状态和 RemoteObject 关联。模型 adapter 可以消费公共 reader,而不依赖 DOM 序列化或 debugger activation。
|
||||
|
||||
source 关闭时,存储的树从 connected 变为 disconnected,而不是删除最后一份 snapshot。对象查询会排除 disconnected 树,因此 snapshot 仍可作为数据检查,但不会保留或复活实时 Context、Fiber 或 Runtime object。同一 source id 的新 transport generation 提交 replacement 后,会原子恢复 connected 状态。可配置的 disconnected tree 数量上限会淘汰最早保留的 snapshot。
|
||||
|
||||
每个被接受的 source snapshot 都会重建 connection-neutral document,并按稳定的 backend node identity 比较差异。只改变 revision 的 replacement 不发送 DOM event;子节点增删使用 `DOM.childNodeInserted` 与 `DOM.childNodeRemoved`,attribute 变化使用对应 DOM event,兄弟节点重排只对该 parent 使用 `DOM.setChildNodes`。只有同一 backend identity 被复用为不同 node kind 时才回退到 `DOM.documentUpdated`。断联只会使 object route 失效,不改变保留的 DOM tree,因此保留展开与选择;达到保留上限时只移除被淘汰的 `<client>` 节点。
|
||||
|
||||
## CDP projection
|
||||
|
||||
synthetic document 包含一个 `<host>` container 和一个 `<clients>` container。`<host>` 包含 Host root Context;`<clients>` 为每个 Client source 包含一个 `<client>`,每个 `<client>` 再包含该 realm 的 root Context。这些结构 element 没有 Runtime object 或 attribute。Context element 没有 attribute。Fiber element 只暴露从 Cordis 原样复制的 `uid`。connected Context 与 Fiber 可以解析为 live RemoteObject;disconnected snapshot 保留 DOM node,但对象解析失败。
|
||||
|
||||
标准 CDP 没有可由 backend 控制、用于普通 Elements 树节点的 frozen、locked 或 dimmed 状态。Chromium 的 detached-node 展示只存在于 Memory 面板的 `DOM.getDetachedDomNodes` 流程,并由 frontend 本地设置。在展示方式明确前,不增加 connection-state attribute 或非标准 `DOM.Node` 字段。
|
||||
|
||||
只读适配器实现 document 获取、子节点请求、节点描述、属性、outer HTML、搜索、backend-id push、节点解析和对象反向查询。修改型 DOM 方法明确失败。layout、CSS、accessibility 与浏览器 DOM geometry 不属于这棵语义树;仅在 Chrome DevTools 需要兼容响应时返回空结果或 unsupported。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**在每个 realm 直接构建 CDP DOM node。** 拒绝,因为 Host 与 Client 会重复分类,frontend id 会泄漏进 source 协议,模型消费方还必须把展示协议反向解析成 Cordis 概念。
|
||||
|
||||
**把实时对象或 V8 object id 发送给 Worker。** 拒绝,因为 structured clone 与 JSON 无法保留身份或行为,而且引擎 object id 只属于一个 inspector session。
|
||||
|
||||
**由 Inspector 生成 Context id。** 拒绝,因为 Cordis Context 没有自身 id,展示适配器不能把实现 key 伪装成框架身份。嵌套 children 表达 parent,不透明 object handle 只作为路由数据。
|
||||
|
||||
**Fiber uid、backend node 与 frontend node 共用一个 id。** 拒绝,因为 source 重连、多条 DevTools 连接、document refresh 与 Runtime object release 的生命周期彼此独立。
|
||||
|
||||
**只暴露 Context,并把每个 Fiber 拥有的 Context 当成 Fiber。** 拒绝,因为这会丢失两类实时对象中的一类,使 Console 身份产生歧义,并让后续 Fiber 专属属性失去稳定 owner。
|
||||
|
||||
**把模型访问 API 放在 CDP 适配器上。** 拒绝,因为模型访问会继承 Chrome 专用 node 序列化、逐连接 id 和 enable 状态。Worker repository 是共享数据源,CDP 与模型访问是并列适配器。
|
||||
|
||||
**source 断联时移除 realm 树。** 拒绝,因为传输中断会丢失最后一份有用拓扑,并折叠用户在 Elements 中的检查状态。继续使用旧 object handle 同样不可接受:新的连接 generation 无法证明任何先前实时对象仍然存在。
|
||||
|
||||
## Verification
|
||||
|
||||
- 同一个 collector 实现能从等价 Cordis 运行时生成 Host 和 Client 快照。
|
||||
- Elements 显示 `<host>` 与 `<clients>/<client>` container,每个 realm 的 root Context 直接位于其 container 下。
|
||||
- Context element 不含 attribute;Fiber element 只暴露 Cordis `uid`;root Fiber 不出现。
|
||||
- 每个 connected Context 与 Fiber 都有一个连接本地 frontend node id、一个 Worker backend node id 和一个可解析的连接本地 Runtime object id,且它们都不作为 attribute 暴露。
|
||||
- `DOM.resolveNode` 与 `DOM.requestNode` 能往返映射 Context/Fiber 身份,且不会跨 DevTools 连接或 source generation 共享 object id。
|
||||
- Runtime evaluation 返回的 Context 或 Fiber 会被标记为 node,并能在 Elements 中定位。
|
||||
- 断联会销毁 Client execution context 与 RemoteObject,同时原样保留最后一棵 Elements 树;新的 transport generation 在完整 snapshot 到达后替换它。
|
||||
- 重连和 resnapshot 会重放最新树状态;无变化的 snapshot 不发送 DOM mutation,结构变化只更新受影响的 parent 或 node。畸形或超限 replacement 不会替换最后一个有效快照。
|
||||
- 存储的 snapshot 与查询 API 不包含 CDP 类型,可以不加修改地支持未来的模型适配器。
|
||||
|
||||
## Consequences
|
||||
|
||||
Cordis 不提供完整的全局 Context registry。collector 能恢复从 live fiber 与 event hook 可达的 Context;一个已创建、从未使用且只由应用代码保留的 Context 会有意缺席。
|
||||
|
||||
需要语义识别的每个 Host object 都会增加一次 Runtime round trip。annotation 失败时保留普通 RemoteObject,不破坏 Runtime 或 Debugger 投递。Client Console observation 保留原始 method result,并在之后调度序列化;每个已启用 DevTools session 独立保留 handle,因此识别既不阻塞页面调用,也不在连接间共享对象。
|
||||
|
||||
source 仍发布完整 snapshot,从而复用同一套 Host/Client collector,并能在 observation 丢失后恢复。Worker 承担 snapshot 比较成本,再发送增量 CDP DOM mutation,使无变化的 revision 不会重置 Elements document。节点数与字节数限制会保留有效前缀并报告截断;以后可以替换 source delta 协议,而不修改 snapshot model 或 CDP projection。
|
||||
|
||||
对象表会有意强引用当前可见树中的每个对象,直到下一次 replacement 或 observer dispose。该集合受保留快照限制,不能扩展成通用对象注册表。
|
||||
|
||||
Worker 对断联 snapshot 只保留序列化 metadata;仍在运行的 source 独立拥有其 realm-local object registry,dispose 会释放该 registry。`maxDisconnectedCordisTrees` 约束 Worker snapshot 内存;淘汰时会移除对应的已保留 Client subtree。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.md
|
||||
2026-08-26-inspector-execution-realms-and-protocol-planes.md: e8bff0661d2d0c86c216b0a18e2feb7a2c786709
|
||||
2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md: 2db6307d1bfc536ac5e8b0f5d6f03e4cfe334989
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
# Agent Note: Inspector execution realms and protocol planes
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The Inspector package executes code in three JavaScript environments: the browser Client, the Host Node main thread, and an Inspector Worker thread. Without execution-oriented directories, feature names alone do not establish where code runs or which identifiers it may own.
|
||||
|
||||
This ambiguity is risky because Host and Client support intentionally differs while their architecture must remain comparable. Host Runtime and Debugger delegate to Node's inspector protocol; Client Runtime and Console simulate the same backend semantics over an internal bridge. If their files, interfaces, and unsupported operations diverge structurally, each new protocol method encourages a second routing model. Likewise, consumers that only need the Cordis runtime tree must not inherit debugger activation, Chrome connection state, or CDP identifiers.
|
||||
|
||||
The [cross-realm CDP inspector decision](2026-08-23-cross-realm-cdp-inspector.md) owns Worker, transport, Runtime, debugger, and security behavior. The [Cordis runtime tree inspection decision](2026-08-24-cordis-runtime-tree-inspection.md) owns Cordis tree semantics, object routing, and DOM projection. This decision owns source placement, dependency direction, and the separation between domain data, backend semantics, internal transport, and Chrome CDP state.
|
||||
|
||||
## Decision
|
||||
|
||||
Top-level source directories identify execution ownership. `client/` contains only browser Client code, `host/` only Host Node-main-thread code, `worker/` only Worker-thread code, and `shared/` code that is safe in every environment. A module that executes in the Worker on behalf of a Client belongs under `worker/`, not `client/`.
|
||||
|
||||
The repository-required `src/index.ts` and `src/invariant.ts` discovery entries are the only root-level source exceptions. They expose the Host package entry and its service type or register the invariant companion, contain no Inspector runtime implementation, and remain at fixed paths for repository tooling.
|
||||
|
||||
```text
|
||||
src/
|
||||
shared/ environment-independent data and interfaces
|
||||
client/ browser Client producer and adapters
|
||||
host/ Host Node-main-thread producer and adapters
|
||||
worker/ Worker transport, repositories, realm backends, and CDP endpoint
|
||||
```
|
||||
|
||||
`client/` and `host/` have the same relative directories and filenames. Their common roles are plugin entry, bridge lifecycle and RPC, Cordis and network inspection, and CDP-oriented Runtime, Console, Debugger, Sources, Profiler, and HeapProfiler adapters. Support may differ: an unavailable operation remains in the corresponding mirrored module and returns the shared capability-unavailable or typed-unsupported result. Mirroring standardizes where a capability is implemented; it does not claim equal engine support.
|
||||
|
||||
Worker-side realm adapters use the same rule under `worker/realms/client/` and `worker/realms/host/`. These adapters normalize Client simulation and Node inspector behavior behind shared CDP-oriented backend interfaces. They do not own Chrome wire messages or connection-local CDP identifiers.
|
||||
|
||||
## Execution ownership
|
||||
|
||||
`client/` owns page-realm observation, Client object handles, browser evaluation, browser Console interception, Client source publication, and its direct authenticated bridge to the Worker. It may use browser APIs but not Node or Worker implementation modules.
|
||||
|
||||
`host/` owns Cordis plugin composition on the Node main thread, Worker startup and disposal, Host object observation, fetch capture, Node inspector notification forwarding, and the Host side of the Worker bridge. It may use Node APIs but does not construct Chrome CDP responses.
|
||||
|
||||
`worker/bridge/` owns source admission, transport endpoints, connection generations, frame dispatch, correlation, and routing between source producers and Worker consumers. `worker/inspection/` owns retained Cordis and network observations plus transport-independent queries. `worker/realms/` owns the normalized Host and Client runtime backends. `worker/cdp/` owns HTTP discovery, DevTools sessions, Chrome method dispatch, domain enable state, and every connection-local Chrome identifier.
|
||||
|
||||
The Worker remains the sole Chrome CDP wire and state owner. Client code simulates shared backend operations, not the CDP wire. Host code delegates supported backend operations to Node inspector, but Node protocol identifiers are translated inside the Worker Host realm before common domain projection.
|
||||
|
||||
## Data and identifier ownership
|
||||
|
||||
`shared/cordis/` contains the CDP-independent semantic model, immutable snapshots, collection and observation, realm-local object registration, projections, and reader interfaces. `model.ts` contains no transport handles or CDP identifiers. `snapshot.ts` may carry a realm-local opaque object reference because a live object query needs that route, but consumers can project it away.
|
||||
|
||||
`shared/network/` contains fetch and network observations, captured body representation, and header normalization. These records describe observed activity and do not contain CDP request ids or domain enable state.
|
||||
|
||||
`shared/cdp/` contains normalized backend interfaces and values for realm capabilities, Runtime, Console, Debugger, Sources, Profiler, HeapProfiler, and typed unsupported results. Backend handles in these interfaces are opaque and realm-owned. They are not Chrome `RemoteObjectId`, `ExecutionContextId`, `ScriptId`, or `CallFrameId` values.
|
||||
|
||||
`shared/bridge/` contains the versioned internal carrier: source and generation identifiers, envelopes, codecs, validation, bounded publication, RPC correlation, dispatch interfaces, and domain-specific message unions. Its message modules may transport Cordis snapshots, network observations, Console events, Runtime operations, source reads, debugger operations, and semantic queries without turning those values into CDP messages.
|
||||
|
||||
`worker/cdp/ids.ts` is the only owner of Chrome connection-local identifiers such as `RemoteObjectId`, `ExecutionContextId`, `ScriptId`, `NodeId`, and `CallFrameId`. Worker domain sessions allocate and release them and map them to realm backend handles or inspection records. Source, generation, sequence, request, Cordis Fiber uid, realm object reference, backend handle, and Chrome id remain distinct types because their owners and lifetimes differ.
|
||||
|
||||
## Dependency rules
|
||||
|
||||
The domain modules `shared/cordis/`, `shared/network/`, and `shared/cdp/` do not import `shared/bridge/` or any execution-specific directory. `shared/bridge/` may import those domain types when defining internal messages. No module under `shared/` imports Node-only or browser-only APIs.
|
||||
|
||||
Top-level `client/` and `host/` import `shared/` but never each other or `worker/`. Equivalent roles use equivalent shared interfaces. Environment-specific transport and engine behavior stays in the mirrored implementation file rather than entering a shared conditional implementation.
|
||||
|
||||
`worker/realms/` and `worker/inspection/` import shared interfaces but do not import `worker/cdp/`; normalized backend results and stored observations cannot contain Chrome connection state. `worker/cdp/` may consume realm and inspection interfaces to project CDP. `worker/bridge/` routes shared messages and invokes Worker services without becoming an owner of Cordis, network, Runtime, or Chrome state.
|
||||
|
||||
The package remains one `@deepseek-ai/dsh-experimental-inspector` package with explicit Client and Host compiler faces. Directory separation is an execution and dependency rule, not a package split.
|
||||
|
||||
## Verification
|
||||
|
||||
- Every runtime implementation has an unambiguous execution owner through `shared/`, `client/`, `host/`, or `worker/`; only the repository-required package and invariant forwarding entries remain at the source root.
|
||||
- Top-level Client and Host trees, and Worker Client and Host realm trees, have identical relative implementation paths; unequal capability support is explicit and typed.
|
||||
- Cordis and network readers are usable without importing debugger, source, transport, or CDP session modules.
|
||||
- Internal messages contain source-level identities and validated domain values but no Chrome connection-local ids.
|
||||
- Normalized realm backend interfaces support Host delegation and Client simulation without either implementation constructing Chrome CDP messages.
|
||||
- Only Worker CDP modules allocate Chrome ids and own DevTools connection enable, object, script, node, and call-frame state.
|
||||
- Host Runtime and debugging, Client Runtime and Console, Network capture, Cordis Elements projection, disconnect retention, and semantic query behavior have focused coverage.
|
||||
- Compiler faces, import checks, and the structural layout test reject environment leaks and Client/Host mirror drift.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Organize every file by feature domain.** Rejected because a Runtime or Cordis feature spans three environments with different available APIs. Feature-only paths conceal execution constraints and make accidental browser-to-Node imports difficult to review.
|
||||
|
||||
**Put Worker Client and Host adapters in top-level `client/` and `host/`.** Rejected because those adapters execute in the Worker and own different resources from page and Node-main-thread producers. A directory name must answer where code runs before it answers which remote realm it represents.
|
||||
|
||||
**Allow Client and Host trees to contain only currently supported files.** Rejected because asymmetric layout obscures missing capability decisions and lets equivalent routing roles acquire unrelated interfaces. Explicit unsupported implementations keep evolution exhaustive without pretending support exists.
|
||||
|
||||
**Keep one shared protocol directory.** Rejected because internal carrier identities, Cordis semantic data, normalized Runtime values, and Chrome wire identifiers have different consumers and lifetimes. A single directory encourages domain models to depend on transport and CDP presentation.
|
||||
|
||||
**Split Client, Host, protocol, and Worker into separate packages.** Rejected for the experimental phase. The deployment unit remains one Client/Host Cordis plugin, and package boundaries would add build and release coordination without improving the required execution separation.
|
||||
|
||||
## Consequences
|
||||
|
||||
Exact mirroring adds small adapter files for unsupported capabilities. Those files are intentional compatibility points between implementations, but they must stay thin and must not manufacture fake behavior.
|
||||
|
||||
Moving types without changing behavior can still expose hidden dependency cycles, especially where Runtime object annotation reaches Cordis repositories. The dependency rules require inversion through shared interfaces rather than a temporary import from a lower-level module.
|
||||
|
||||
`shared/cdp/` can become a second copy of the Chrome protocol if normalized types are added indiscriminately. A shared type belongs there only when both realm implementations or a common Worker projector consume it; Chrome session bookkeeping and wire-only fields remain under `worker/cdp/`.
|
||||
|
||||
Explicit Client and Host compiler faces and focused behavior tests add maintenance work, but they keep environment leaks and mirror drift visible.
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
# Agent Note: Inspector 执行环境与协议平面
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-26-inspector-execution-realms-and-protocol-planes.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
Inspector 包的代码运行在三个 JavaScript 环境中:浏览器 Client、Host Node 主线程和 Inspector Worker thread。只按功能命名目录时,文件路径无法说明代码在哪里运行,也无法说明它可以持有哪些标识符。
|
||||
|
||||
这种含糊会带来风险,因为 Host 与 Client 的支持能力有意不同,但架构必须保持可比较。Host Runtime 与 Debugger 委托 Node inspector protocol;Client Runtime 与 Console 通过内部 bridge 模拟同一套 backend 语义。如果两边的文件、接口与 unsupported operation 在结构上分叉,每增加一种协议方法都容易产生第二套路由模型。同样,只需要 Cordis 运行时树的消费方不应继承 debugger activation、Chrome 连接状态或 CDP 标识符。
|
||||
|
||||
现有的[跨 realm CDP Inspector 决策](2026-08-23-cross-realm-cdp-inspector.zh.md)负责 Worker、transport、Runtime、debugger 与安全行为。[Cordis 运行时树检查决策](2026-08-24-cordis-runtime-tree-inspection.zh.md)负责 Cordis 树语义、对象路由与 DOM projection。本决策负责源码位置、依赖方向,以及领域数据、backend 语义、内部 transport 和 Chrome CDP 状态之间的分隔。
|
||||
|
||||
## Decision
|
||||
|
||||
顶层源码目录标识执行归属。`client/` 只包含浏览器 Client 代码,`host/` 只包含 Host Node 主线程代码,`worker/` 只包含 Worker thread 代码,`shared/` 只包含在所有环境中都安全的代码。即使某个模块代表 Client,只要它实际在 Worker 中执行,就仍属于 `worker/`,而不是 `client/`。
|
||||
|
||||
仓库要求的 `src/index.ts` 与 `src/invariant.ts` 发现入口是仅有的源码根目录例外。它们暴露 Host package entry 及其 service type,或注册 invariant companion,不包含 Inspector 运行时实现,并为仓库工具保留在固定路径。
|
||||
|
||||
```text
|
||||
src/
|
||||
shared/ environment-independent data and interfaces
|
||||
client/ browser Client producer and adapters
|
||||
host/ Host Node-main-thread producer and adapters
|
||||
worker/ Worker transport, repositories, realm backends, and CDP endpoint
|
||||
```
|
||||
|
||||
`client/` 与 `host/` 拥有相同的相对目录和文件名。共同角色包括 plugin entry、bridge lifecycle 与 RPC、Cordis 和 network inspection,以及面向 CDP 的 Runtime、Console、Debugger、Sources、Profiler 和 HeapProfiler adapter。支持程度可以不同:不可用的操作仍保留在对应的镜像模块中,并返回共享的 capability-unavailable 或类型化 unsupported 结果。镜像结构统一的是能力实现位置,而不是宣称两个引擎支持相同功能。
|
||||
|
||||
Worker 侧 realm adapter 在 `worker/realms/client/` 与 `worker/realms/host/` 下遵守相同规则。这些 adapter 通过共享的面向 CDP backend 接口规范化 Client 模拟行为与 Node inspector 行为。它们不拥有 Chrome wire message 或连接局部的 CDP 标识符。
|
||||
|
||||
## Execution ownership
|
||||
|
||||
`client/` 负责 page realm observation、Client object handle、浏览器求值、浏览器 Console interception、Client source publication 以及到 Worker 的直接鉴权 bridge。它可以使用浏览器 API,但不能导入 Node 或 Worker 实现模块。
|
||||
|
||||
`host/` 负责 Node 主线程上的 Cordis plugin composition、Worker 启动与 dispose、Host object observation、fetch capture、Node inspector notification forwarding,以及 Worker bridge 的 Host 一侧。它可以使用 Node API,但不构造 Chrome CDP response。
|
||||
|
||||
`worker/bridge/` 负责 source admission、transport endpoint、connection generation、frame dispatch、correlation,以及 source producer 与 Worker consumer 之间的路由。`worker/inspection/` 负责保留的 Cordis 与 network observation,以及不依赖 transport 的 query。`worker/realms/` 负责规范化的 Host 与 Client runtime backend。`worker/cdp/` 负责 HTTP discovery、DevTools session、Chrome method dispatch、domain enable 状态与所有连接局部的 Chrome 标识符。
|
||||
|
||||
Worker 继续作为唯一的 Chrome CDP wire 与状态 owner。Client 代码模拟共享 backend operation,而不是模拟 CDP wire。Host 代码把支持的 backend operation 委托给 Node inspector,但 Node protocol 标识符在 Worker Host realm 内转换后才进入公共 domain projection。
|
||||
|
||||
## Data and identifier ownership
|
||||
|
||||
`shared/cordis/` 包含与 CDP 无关的语义模型、不可变 snapshot、collection 与 observation、realm-local object registration、projection 与 reader interface。`model.ts` 不包含 transport handle 或 CDP 标识符。`snapshot.ts` 可以携带 realm-local opaque object reference,因为实时对象查询需要该路由信息,但消费方可以在 projection 中移除它。
|
||||
|
||||
`shared/network/` 包含 fetch 与 network observation、采集 body 表示及 header normalization。这些记录描述已观测活动,不包含 CDP request id 或 domain enable 状态。
|
||||
|
||||
`shared/cdp/` 包含 realm capability、Runtime、Console、Debugger、Sources、Profiler、HeapProfiler 的规范化 backend 接口和值,以及类型化 unsupported 结果。这些接口中的 backend handle 是不透明且由 realm 持有的。它们不是 Chrome `RemoteObjectId`、`ExecutionContextId`、`ScriptId` 或 `CallFrameId`。
|
||||
|
||||
`shared/bridge/` 包含带版本的内部 carrier:source 与 generation 标识符、envelope、codec、validation、有限 publication、RPC correlation、dispatch interface 及分领域的 message union。其 message 模块可以传输 Cordis snapshot、network observation、Console event、Runtime operation、source read、debugger operation 与语义 query,但不会把这些值转换成 CDP message。
|
||||
|
||||
`worker/cdp/ids.ts` 是 Chrome 连接局部标识符的唯一 owner,包括 `RemoteObjectId`、`ExecutionContextId`、`ScriptId`、`NodeId` 与 `CallFrameId`。Worker domain session 分配并释放这些 id,把它们映射到 realm backend handle 或 inspection record。source、generation、sequence、request、Cordis Fiber uid、realm object reference、backend handle 与 Chrome id 必须保持为不同类型,因为它们的 owner 和生命周期不同。
|
||||
|
||||
## Dependency rules
|
||||
|
||||
领域模块 `shared/cordis/`、`shared/network/` 与 `shared/cdp/` 不导入 `shared/bridge/` 或任何执行环境专属目录。`shared/bridge/` 在定义内部 message 时可以导入这些领域类型。`shared/` 下的任何模块都不导入 Node-only 或 browser-only API。
|
||||
|
||||
顶层 `client/` 与 `host/` 可以导入 `shared/`,但不能互相导入,也不能导入 `worker/`。等价角色使用等价的共享接口。环境专属 transport 与 engine 行为保留在对应镜像实现文件中,不进入带条件分支的共享实现。
|
||||
|
||||
`worker/realms/` 与 `worker/inspection/` 可以导入共享接口,但不导入 `worker/cdp/`;规范化 backend result 和已存 observation 不能包含 Chrome connection state。`worker/cdp/` 可以消费 realm 与 inspection interface 来生成 CDP projection。`worker/bridge/` 路由共享 message 并调用 Worker service,但不成为 Cordis、network、Runtime 或 Chrome 状态的 owner。
|
||||
|
||||
本能力继续保留在同一个 `@deepseek-ai/dsh-experimental-inspector` 包中,并使用显式 Client 与 Host compiler face。目录分隔是执行与依赖规则,不是拆包方案。
|
||||
|
||||
## Verification
|
||||
|
||||
- 每个运行时实现都通过 `shared/`、`client/`、`host/` 或 `worker/` 拥有明确的执行 owner;只有仓库要求的 package 与 invariant 转发入口留在源码根目录。
|
||||
- 顶层 Client/Host 树与 Worker Client/Host realm 树分别拥有相同的相对实现路径;不同能力支持使用显式类型表示。
|
||||
- Cordis 与 network reader 无需导入 debugger、source、transport 或 CDP session 模块即可使用。
|
||||
- 内部 message 包含 source 层 identity 与已验证领域值,但不包含 Chrome 连接局部 id。
|
||||
- 规范化 realm backend interface 同时支持 Host 委托与 Client 模拟,且两种实现都不构造 Chrome CDP message。
|
||||
- 只有 Worker CDP 模块分配 Chrome id,并持有 DevTools 连接的 enable、object、script、node 与 call-frame 状态。
|
||||
- Host Runtime 与 debugging、Client Runtime 与 Console、Network capture、Cordis Elements projection、断联保留与语义 query 行为均有聚焦测试覆盖。
|
||||
- compiler face、import check 与结构测试能够拒绝环境泄漏和 Client/Host 镜像漂移。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**所有文件都按功能领域组织。** 拒绝,因为一个 Runtime 或 Cordis 功能会跨越三个可用 API 不同的环境。只有功能信息的路径会隐藏执行限制,也让 browser 到 Node 的意外导入难以审查。
|
||||
|
||||
**把 Worker Client 与 Host adapter 放入顶层 `client/` 和 `host/`。** 拒绝,因为这些 adapter 实际运行在 Worker 中,持有的资源也不同于 page 与 Node 主线程 producer。目录名应先回答代码在哪里运行,再回答它代表哪个远端 realm。
|
||||
|
||||
**Client 与 Host 目录只保留当前支持的文件。** 拒绝,因为不对称目录会隐藏缺失能力决策,并允许等价路由角色形成无关接口。显式 unsupported 实现既保证穷尽演进,也不虚构已支持行为。
|
||||
|
||||
**保留一个共享 protocol 目录。** 拒绝,因为内部 carrier identity、Cordis 语义数据、规范化 Runtime value 与 Chrome wire identifier 的消费方和生命周期不同。单一目录会诱导领域模型依赖 transport 和 CDP presentation。
|
||||
|
||||
**把 Client、Host、protocol 与 Worker 拆成多个包。** 实验阶段拒绝。部署单元仍是一个 Client/Host Cordis plugin;包边界会增加构建和发布协作,却不能改善所需的执行环境分隔。
|
||||
|
||||
## Consequences
|
||||
|
||||
严格镜像会为不支持的能力增加小型 adapter 文件。这些文件是两个实现之间有意保留的兼容点,但必须保持轻薄,也不能制造虚假行为。
|
||||
|
||||
即使只移动类型而不改变行为,也可能暴露隐藏的依赖环,尤其是 Runtime object annotation 访问 Cordis repository 的位置。依赖规则要求通过共享接口反转依赖,不能临时从较低层模块反向导入。
|
||||
|
||||
如果不加约束地添加规范化类型,`shared/cdp/` 可能变成第二份 Chrome protocol。只有两个 realm 实现或公共 Worker projector 会消费的类型才属于这里;Chrome session bookkeeping 与 wire-only field 保留在 `worker/cdp/`。
|
||||
|
||||
显式 Client/Host compiler face 与聚焦行为测试增加了维护工作,但会持续暴露环境泄漏和镜像结构漂移。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-27-inspector-development-mount.md
|
||||
2026-08-27-inspector-development-mount.md: 0e5f0af52306ebb553e4fb911696cfc3fae087ee
|
||||
2026-08-27-inspector-development-mount.zh.md: 252e52692df2ad983e6da9ee9640c5ae6603f20f
|
||||
@@ -0,0 +1,30 @@
|
||||
# Agent Note: Inspector development mount
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-27-inspector-development-mount.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`@deepseek-ai/dsh-experimental-inspector` is a private package no published dsh installation carries, yet development launches need to mount it into the shipped Web composition on demand. A row in a shipped bundle patch cannot express this: `verify-cordis-config` requires every named row of a bundle patch to resolve from that bundle's own `dependencies` — disabled rows included — and a published manifest must not depend on an unpublished package.
|
||||
|
||||
## Decision
|
||||
|
||||
The inspector package owns a development overlay, `packages/experimental/inspector/cordis.patch.yml`, holding a single `insert` of the `experimental-inspector` row. A launch selects it through the generic overlay flag; `pnpm run demo:inspector` is the shorthand for `pnpm dsh web --patch ./packages/experimental/inspector/cordis.patch.yml`.
|
||||
|
||||
The overlay contributes only the row; the row's module resolves from the profile plane at entry import:
|
||||
|
||||
- A source launch (`pnpm dsh`, tsx) resolves the workspace package through the tsconfig `paths` facade and needs no installation.
|
||||
- A built launch (`node apps/cli/lib/bin.js`) needs the package importable from the profile first: `dsh plugin --profile web add link:<absolute package path>`, once per profile. `link:` keeps dependency resolution inside the real package directory; `file:` re-installs the package's `workspace:^` dependencies in the profile and fails with `ERR_PNPM_WORKSPACE_PKG_NOT_FOUND`.
|
||||
|
||||
A launch whose profile cannot import the package fails loud at entry import (`Cannot find package '@deepseek-ai/dsh-experimental-inspector' imported from <profile dir>`); nothing is skipped silently.
|
||||
|
||||
## Consequences
|
||||
|
||||
Published packages carry no trace of the inspector: no manifest entry, no composition row, no launcher flag. Mounting stays a per-launch choice — the same service without the overlay never loads the package — and every layer the launch composes is declared in a config file. The cost is launch-mode asymmetry: a built launch needs the one-time profile `link:` install, and the overlay must be named on every invocation, which `pnpm run demo:inspector` absorbs for the common case.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- A `disabled: !!js` row in the shipped web-app patch: the dependency gate and npm publication both force the private package into the published manifest.
|
||||
- A `--inspector` launcher flag mounting the package as an extra bundle layer: the launcher owns neither app flags nor plugin package names.
|
||||
- An optional `peerDependencies` entry on `dsh-web-app` plus a dynamic `ctx.loader.create` from its glue plugin: it writes a never-published name into a published manifest and mounts a row no config layer declares.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Agent Note:Inspector 开发挂载
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-27-inspector-development-mount.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
`@deepseek-ai/dsh-experimental-inspector` 是任何已发布 dsh 安装都不携带的 private 包,但开发启动需要按需把它挂进随货 Web 组合。随货 bundle patch 里的一行表达不了这件事:`verify-cordis-config` 要求 bundle patch 中每个具名行都能从该 bundle 自己的 `dependencies` 解析——disabled 行也不豁免——而已发布的 manifest 不得依赖未发布的包。
|
||||
|
||||
## Decision
|
||||
|
||||
inspector 包自有一份开发 overlay,`packages/experimental/inspector/cordis.patch.yml`,只含一个 `insert` 的 `experimental-inspector` 行。启动通过通用 overlay flag 选它;`pnpm run demo:inspector` 是 `pnpm dsh web --patch ./packages/experimental/inspector/cordis.patch.yml` 的简写。
|
||||
|
||||
overlay 只贡献这一行;行的模块在 entry import 时从 profile 平面解析:
|
||||
|
||||
- 源码启动(`pnpm dsh`,tsx)经 tsconfig `paths` 门面解析 workspace 包,无需任何安装。
|
||||
- built 启动(`node apps/cli/lib/bin.js`)需先让包可从 profile import:`dsh plugin --profile web add link:<包目录绝对路径>`,每个 profile 一次。`link:` 让依赖解析留在真实包目录内;`file:` 会在 profile 里重装该包的 `workspace:^` 依赖并以 `ERR_PNPM_WORKSPACE_PKG_NOT_FOUND` 失败。
|
||||
|
||||
profile 无法 import 该包的启动会在 entry import 处响亮失败(`Cannot find package '@deepseek-ai/dsh-experimental-inspector' imported from <profile dir>`);不存在静默跳过。
|
||||
|
||||
## Consequences
|
||||
|
||||
已发布的包不携带 inspector 的任何痕迹:没有 manifest 条目、没有组合行、没有 launcher flag。挂载保持按次启动选择——不带 overlay 的同一服务永远不会加载该包——且启动组合的每一层都由 config 文件声明。代价是启动方式不对称:built 启动需要一次性 profile `link:` 安装,且每次调用都要点名 overlay,常见场景由 `pnpm run demo:inspector` 吸收。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- 随货 web-app patch 里放 `disabled: !!js` 行:依赖门禁与 npm 发布都会把 private 包逼进已发布 manifest。
|
||||
- `--inspector` launcher flag 把包挂成额外 bundle 层:launcher 既不拥有 app flag 也不拥有插件包名。
|
||||
- `dsh-web-app` 上加 optional `peerDependencies` 并由其 glue 插件动态 `ctx.loader.create`:向已发布 manifest 写入永不发布的名字,且挂载的行不在任何 config 层声明。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/capability-seams.md
|
||||
capability-seams.md: 1e7e6e39d307a9e72b5d57420bde99f51063f64d
|
||||
capability-seams.zh.md: e33a1e6da7f71838c48b961f93389ba1a089f57f
|
||||
capability-seams.md: 3886ca582ea934c51fc20dfec01fd9f2af829597
|
||||
capability-seams.zh.md: a79b93bb8d6fff36e0828dbba8f7e20b885bcbfe
|
||||
|
||||
@@ -177,6 +177,8 @@ flowchart LR
|
||||
svc_agentTeams["ctx.agentTeams<br/>Agent Teams coordination domain"]
|
||||
pkg_experimental_tool_agent_team["experimental-tool-agent-team"]
|
||||
pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"]
|
||||
pkg_inspector["inspector"]
|
||||
svc_inspector["ctx.inspector<br/>Cross-realm runtime inspection"]
|
||||
pkg_jobs["jobs"]
|
||||
svc_jobs["ctx.jobs<br/>Background job registry"]
|
||||
pkg_jobs_local["jobs-local"]
|
||||
@@ -256,6 +258,7 @@ flowchart LR
|
||||
pkg_host_directory_picker_browse --> svc_directoryPicker
|
||||
pkg_host_directory_picker_native --> svc_directoryPicker
|
||||
pkg_host_webserver --> svc_webServer
|
||||
pkg_inspector --> svc_inspector
|
||||
pkg_invariants --> svc_invariants
|
||||
pkg_jobs --> svc_jobs
|
||||
pkg_jobs_local --> svc_jobs
|
||||
@@ -513,6 +516,7 @@ flowchart LR
|
||||
| `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; there is no model-facing compact tool. |
|
||||
| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. |
|
||||
| `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | Owns the implicit-root roster, durable peer mailbox, shared task DAG, continuable-child lifecycle, and generated Team Remote methods; tool-agent-team contributes model controls and client-ui-agent-team mounts the browser contribution. |
|
||||
| `ctx.inspector` | `core` | `inspector` | - | - | - | Owns the Worker-hosted CDP target and the transport-independent Host and Client observation and Cordis-tree query API. |
|
||||
| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. |
|
||||
| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. |
|
||||
| `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. |
|
||||
|
||||
@@ -179,6 +179,8 @@ flowchart LR
|
||||
svc_agentTeams["ctx.agentTeams<br/>Agent Teams coordination domain"]
|
||||
pkg_experimental_tool_agent_team["experimental-tool-agent-team"]
|
||||
pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"]
|
||||
pkg_inspector["inspector"]
|
||||
svc_inspector["ctx.inspector<br/>Cross-realm runtime inspection"]
|
||||
pkg_jobs["jobs"]
|
||||
svc_jobs["ctx.jobs<br/>Background job registry"]
|
||||
pkg_jobs_local["jobs-local"]
|
||||
@@ -258,6 +260,7 @@ flowchart LR
|
||||
pkg_host_directory_picker_browse --> svc_directoryPicker
|
||||
pkg_host_directory_picker_native --> svc_directoryPicker
|
||||
pkg_host_webserver --> svc_webServer
|
||||
pkg_inspector --> svc_inspector
|
||||
pkg_invariants --> svc_invariants
|
||||
pkg_jobs --> svc_jobs
|
||||
pkg_jobs_local --> svc_jobs
|
||||
@@ -515,6 +518,7 @@ flowchart LR
|
||||
| `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 基础后端消费步骤后的压力事件和请求错误恢复事件;不存在面向模型的压缩工具。 |
|
||||
| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | 提供方实现传输;该服务还负责可选的、基于 Activation 的延续编排,tool-subagent 选择一次性或可延续委派,tool-subagent-control 传递后续消息,而 tool-ralph 要求一条全新的结构化输出路由。 |
|
||||
| `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | 负责隐式 Root roster、持久 peer mailbox、共享任务 DAG、continuable child 生命周期与生成式 Team Remote method;tool-agent-team 提供模型控制工具,client-ui-agent-team 挂载浏览器 contribution。 |
|
||||
| `ctx.inspector` | `core` | `inspector` | - | - | - | 负责 Worker 托管的 CDP target,以及独立于传输的 Host 和 Client observation 与 Cordis tree query API。 |
|
||||
| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | 生产方(后台 bash、PTY 发送和 subagent 委派)登记正在运行的工作;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 |
|
||||
| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | 搜索和抓取提供方注册到同一个 ctx.web seam;tool-web 负责稳定的面向模型名称。 |
|
||||
| `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | 后端保存过大的工具文本,并返回面向模型的定位信息和取回提示;spill-policy 是 tools/post-execute 消费方,负责决定何时 spill。 |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/config-catalog.md
|
||||
config-catalog.md: ab16221ff6c13768c9b0fb6a8189e30565dcc289
|
||||
config-catalog.zh.md: 8c9d956ad3ad5f672f73e5b4dd02aaed938c8667
|
||||
config-catalog.md: 4331c0a5153f32f0e6af5b6ec6fd182ee9b335b4
|
||||
config-catalog.zh.md: 54f6ddde10053d422af2c5ebfd88a367597adc89
|
||||
|
||||
@@ -612,6 +612,74 @@ export interface Config {
|
||||
|
||||
Source: [`packages/experimental/agent-team/src/types.ts:131`](../packages/experimental/agent-team/src/types.ts)
|
||||
|
||||
<a id="deepseek-aidsh-experimental-inspector"></a>
|
||||
|
||||
## `@deepseek-ai/dsh-experimental-inspector`
|
||||
|
||||
Requires: `webServer`
|
||||
|
||||
```ts config-catalog
|
||||
/** Host plugin configuration. Fetch capture is enabled by default. */
|
||||
export interface Config extends Omit<InspectorOptions, 'clientOrigins'> {
|
||||
/** Browser origins allowed to open the Client ingest WebSocket. */
|
||||
clientOrigins?: string[]
|
||||
}
|
||||
|
||||
/** User-facing Host options; every memory and lifecycle bound is configurable. */
|
||||
export interface InspectorOptions {
|
||||
/** Loopback address used by the Worker HTTP and WebSocket endpoint. */
|
||||
readonly host?: '127.0.0.1'
|
||||
/** First port to bind; occupied ports advance until one is available. */
|
||||
readonly port?: number
|
||||
/** Additional exact browser origins admitted to the Client ingest socket. */
|
||||
readonly clientOrigins?: readonly string[]
|
||||
/** Whether to observe calls made through the current global fetch function. */
|
||||
readonly captureFetch?: boolean
|
||||
/** Maximum request-body prefix retained for one fetch. */
|
||||
readonly maxRequestBodyBytes?: number
|
||||
/** Maximum response-body prefix retained for one fetch. */
|
||||
readonly maxResponseBodyBytes?: number
|
||||
/** Maximum raw bytes encoded into one body observation. */
|
||||
readonly maxBodyChunkBytes?: number
|
||||
/** Maximum total request and response body bytes retained by the Worker. */
|
||||
readonly maxJournalBytes?: number
|
||||
/** Maximum active and completed fetch requests retained by the Worker. */
|
||||
readonly maxRetainedRequests?: number
|
||||
/** Maximum encoded bytes accepted in one source transport frame. */
|
||||
readonly maxSourceFrameBytes?: number
|
||||
/** Maximum observation records accepted in one source batch. */
|
||||
readonly maxSourceRecordsPerFrame?: number
|
||||
/** Maximum records waiting in one producer queue. */
|
||||
readonly maxQueuedRecords?: number
|
||||
/** Maximum encoded bytes waiting in one producer queue. */
|
||||
readonly maxQueuedBytes?: number
|
||||
/** Maximum time allowed for the Worker to become ready. */
|
||||
readonly startupTimeoutMs?: number
|
||||
/** Grace period before a stopping Worker is terminated. */
|
||||
readonly stopTimeoutMs?: number
|
||||
/** Initial upper bound for randomized Client reconnect delay. */
|
||||
readonly clientReconnectBaseMs?: number
|
||||
/** Maximum upper bound for randomized Client reconnect delay. */
|
||||
readonly clientReconnectMaxMs?: number
|
||||
/** Deadline for one Worker-to-Client Runtime or Sources request. */
|
||||
readonly clientRuntimeTimeoutMs?: number
|
||||
/** Deadline for one non-CDP semantic query. */
|
||||
readonly queryTimeoutMs?: number
|
||||
/** Maximum live object handles retained per Client Runtime session. */
|
||||
readonly maxClientRuntimeObjects?: number
|
||||
/** Maximum descriptors returned by one Client property request. */
|
||||
readonly maxClientRuntimeProperties?: number
|
||||
/** Maximum encoded bytes read for one Client script or source map. */
|
||||
readonly maxClientSourceBytes?: number
|
||||
/** Maximum Context and Fiber nodes retained in one realm snapshot. */
|
||||
readonly maxCordisNodes?: number
|
||||
/** Disconnected Cordis snapshots retained after their live realm closes. */
|
||||
readonly maxDisconnectedCordisTrees?: number
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/experimental/inspector/src/index.ts:66`](../packages/experimental/inspector/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-experimental-tool-agent-team"></a>
|
||||
|
||||
## `@deepseek-ai/dsh-experimental-tool-agent-team`
|
||||
|
||||
@@ -614,6 +614,74 @@ export interface Config {
|
||||
|
||||
来源:[`packages/experimental/agent-team/src/types.ts:125`](../packages/experimental/agent-team/src/types.ts)
|
||||
|
||||
<a id="deepseek-aidsh-experimental-inspector"></a>
|
||||
|
||||
## `@deepseek-ai/dsh-experimental-inspector`
|
||||
|
||||
需要:`webServer`
|
||||
|
||||
```ts config-catalog
|
||||
/** Host plugin configuration. Fetch capture is enabled by default. */
|
||||
export interface Config extends Omit<InspectorOptions, 'clientOrigins'> {
|
||||
/** Browser origins allowed to open the Client ingest WebSocket. */
|
||||
clientOrigins?: string[]
|
||||
}
|
||||
|
||||
/** User-facing Host options; every memory and lifecycle bound is configurable. */
|
||||
export interface InspectorOptions {
|
||||
/** Loopback address used by the Worker HTTP and WebSocket endpoint. */
|
||||
readonly host?: '127.0.0.1'
|
||||
/** First port to bind; occupied ports advance until one is available. */
|
||||
readonly port?: number
|
||||
/** Additional exact browser origins admitted to the Client ingest socket. */
|
||||
readonly clientOrigins?: readonly string[]
|
||||
/** Whether to observe calls made through the current global fetch function. */
|
||||
readonly captureFetch?: boolean
|
||||
/** Maximum request-body prefix retained for one fetch. */
|
||||
readonly maxRequestBodyBytes?: number
|
||||
/** Maximum response-body prefix retained for one fetch. */
|
||||
readonly maxResponseBodyBytes?: number
|
||||
/** Maximum raw bytes encoded into one body observation. */
|
||||
readonly maxBodyChunkBytes?: number
|
||||
/** Maximum total request and response body bytes retained by the Worker. */
|
||||
readonly maxJournalBytes?: number
|
||||
/** Maximum active and completed fetch requests retained by the Worker. */
|
||||
readonly maxRetainedRequests?: number
|
||||
/** Maximum encoded bytes accepted in one source transport frame. */
|
||||
readonly maxSourceFrameBytes?: number
|
||||
/** Maximum observation records accepted in one source batch. */
|
||||
readonly maxSourceRecordsPerFrame?: number
|
||||
/** Maximum records waiting in one producer queue. */
|
||||
readonly maxQueuedRecords?: number
|
||||
/** Maximum encoded bytes waiting in one producer queue. */
|
||||
readonly maxQueuedBytes?: number
|
||||
/** Maximum time allowed for the Worker to become ready. */
|
||||
readonly startupTimeoutMs?: number
|
||||
/** Grace period before a stopping Worker is terminated. */
|
||||
readonly stopTimeoutMs?: number
|
||||
/** Initial upper bound for randomized Client reconnect delay. */
|
||||
readonly clientReconnectBaseMs?: number
|
||||
/** Maximum upper bound for randomized Client reconnect delay. */
|
||||
readonly clientReconnectMaxMs?: number
|
||||
/** Deadline for one Worker-to-Client Runtime or Sources request. */
|
||||
readonly clientRuntimeTimeoutMs?: number
|
||||
/** Deadline for one non-CDP semantic query. */
|
||||
readonly queryTimeoutMs?: number
|
||||
/** Maximum live object handles retained per Client Runtime session. */
|
||||
readonly maxClientRuntimeObjects?: number
|
||||
/** Maximum descriptors returned by one Client property request. */
|
||||
readonly maxClientRuntimeProperties?: number
|
||||
/** Maximum encoded bytes read for one Client script or source map. */
|
||||
readonly maxClientSourceBytes?: number
|
||||
/** Maximum Context and Fiber nodes retained in one realm snapshot. */
|
||||
readonly maxCordisNodes?: number
|
||||
/** Disconnected Cordis snapshots retained after their live realm closes. */
|
||||
readonly maxDisconnectedCordisTrees?: number
|
||||
}
|
||||
```
|
||||
|
||||
来源:[`packages/experimental/inspector/src/index.ts:66`](../packages/experimental/inspector/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-experimental-tool-agent-team"></a>
|
||||
|
||||
## `@deepseek-ai/dsh-experimental-tool-agent-team`
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
|
||||
event-producer-consumer.md: 9bd351a204ceb6ab262d2f2b9a0276c44be20cc5
|
||||
event-producer-consumer.zh.md: 453d22632dbcc73edec67a44759f1de42490be88
|
||||
event-producer-consumer.md: e849cb84265c0781e4a8680d0bb247e9955b5c2e
|
||||
event-producer-consumer.zh.md: b5835c56b26f3a75fd792d3a71c3ab2dc688ea42
|
||||
|
||||
@@ -65,7 +65,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) |
|
||||
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:197`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
|
||||
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
|
||||
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `modules` |
|
||||
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `inspector`, `modules` |
|
||||
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
|
||||
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
|
||||
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
|
||||
@@ -78,8 +78,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| Event string | Dispatchers | Listeners |
|
||||
| --- | --- | --- |
|
||||
| `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) |
|
||||
| `internal/plugin` | - | `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules`, `webserver` |
|
||||
| `internal/plugin` | - | `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules`, `webserver` |
|
||||
| `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` |
|
||||
| `internal/status` | - | [`agent`](../packages/core/agent) |
|
||||
| `internal/status` | - | [`agent`](../packages/core/agent), `inspector` |
|
||||
|
||||
Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program.
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) |
|
||||
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:197`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
|
||||
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
|
||||
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `modules` |
|
||||
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `inspector`, `modules` |
|
||||
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
|
||||
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
|
||||
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
|
||||
@@ -80,8 +80,8 @@
|
||||
| 事件字符串 | 派发方 | 监听方 |
|
||||
| --- | --- | --- |
|
||||
| `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) |
|
||||
| `internal/plugin` | - | `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules`, `webserver` |
|
||||
| `internal/plugin` | - | `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules`, `webserver` |
|
||||
| `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` |
|
||||
| `internal/status` | - | [`agent`](../packages/core/agent) |
|
||||
| `internal/status` | - | [`agent`](../packages/core/agent), `inspector` |
|
||||
|
||||
维护模式:生成内容。Cordis 事件声明及生产方/监听方的关系边由仓库的 TypeScript Program 解析。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/module-graph.md
|
||||
module-graph.md: e6013b2915121444f9f1e5ccc172190b8fccc650
|
||||
module-graph.zh.md: 6b491805dbbf7733cbda881552a2b8319d81ecc4
|
||||
module-graph.md: c54901d6951908eaef728b16d295c1f749e7fe22
|
||||
module-graph.zh.md: 67e8c8227035f0bbe411c557aba7ce3fe7b60e03
|
||||
|
||||
@@ -209,6 +209,7 @@ flowchart TD
|
||||
pkg_experimental_agent_team_profile["experimental-agent-team-profile"]
|
||||
pkg_experimental_agent_team_web_profile["experimental-agent-team-web-profile"]
|
||||
pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"]
|
||||
pkg_experimental_inspector["experimental-inspector"]
|
||||
pkg_experimental_tool_agent_team["experimental-tool-agent-team"]
|
||||
pkg_experimental_webworker_packer["experimental-webworker-packer"]
|
||||
pkg_experimental_webworker_runtime["experimental-webworker-runtime"]
|
||||
@@ -437,6 +438,9 @@ flowchart TD
|
||||
pkg_credentials_local --> pkg_home_paths
|
||||
pkg_credentials_local --> pkg_invariants
|
||||
pkg_credentials_local --> pkg_launch_environment
|
||||
pkg_experimental_inspector --> pkg_client_modules
|
||||
pkg_experimental_inspector --> pkg_host_webserver
|
||||
pkg_experimental_inspector --> pkg_invariants
|
||||
pkg_session --> pkg_brand
|
||||
pkg_session --> pkg_invariants
|
||||
pkg_session --> pkg_llm
|
||||
@@ -1751,6 +1755,7 @@ flowchart TD
|
||||
| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) |
|
||||
| [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
|
||||
| [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
|
||||
|
||||
@@ -211,6 +211,7 @@ flowchart TD
|
||||
pkg_experimental_agent_team_profile["experimental-agent-team-profile"]
|
||||
pkg_experimental_agent_team_web_profile["experimental-agent-team-web-profile"]
|
||||
pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"]
|
||||
pkg_experimental_inspector["experimental-inspector"]
|
||||
pkg_experimental_tool_agent_team["experimental-tool-agent-team"]
|
||||
pkg_experimental_webworker_packer["experimental-webworker-packer"]
|
||||
pkg_experimental_webworker_runtime["experimental-webworker-runtime"]
|
||||
@@ -439,6 +440,9 @@ flowchart TD
|
||||
pkg_credentials_local --> pkg_home_paths
|
||||
pkg_credentials_local --> pkg_invariants
|
||||
pkg_credentials_local --> pkg_launch_environment
|
||||
pkg_experimental_inspector --> pkg_client_modules
|
||||
pkg_experimental_inspector --> pkg_host_webserver
|
||||
pkg_experimental_inspector --> pkg_invariants
|
||||
pkg_session --> pkg_brand
|
||||
pkg_session --> pkg_invariants
|
||||
pkg_session --> pkg_llm
|
||||
@@ -1696,7 +1700,7 @@ flowchart TD
|
||||
pkg_client_ui_cordis --> pkg_invariants
|
||||
```
|
||||
|
||||
| Package | Group | Depends on |
|
||||
| 包 | 分组 | 依赖 |
|
||||
| --- | --- | --- |
|
||||
| [`invariants`](../packages/runtime-diagnostics/invariants) | `runtime-diagnostics` | — |
|
||||
| [`atomic-write`](../packages/util/atomic-write) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
@@ -1753,6 +1757,7 @@ flowchart TD
|
||||
| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) |
|
||||
| [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
|
||||
| [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/extensions.md
|
||||
extensions.md: 0418afc7f1b6b6cd892deb618a4f346f6cde720d
|
||||
extensions.zh.md: f2d86add9f1b62913fcc1b89abf1fc1a1d2a178f
|
||||
extensions.md: 540f3c477b4e128b0c1062192185e6276c9e9263
|
||||
extensions.zh.md: ebfe7827484cea2cf8c6d77ca26796f7751d203a
|
||||
|
||||
@@ -256,6 +256,24 @@ Types: [Agent](core.md)
|
||||
|
||||
Source: [`packages/extensions/cordis-host-runner/src/index.ts`](../../packages/extensions/cordis-host-runner/src/index.ts)
|
||||
|
||||
<a id="ctxinspector--inspectorservice"></a>
|
||||
|
||||
### `ctx.inspector` — `InspectorService`
|
||||
|
||||
Shared Host/Client service façade over the realm's source publisher.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Publish one JSON observation without waiting for Worker delivery.
|
||||
* @param topic - Domain-owned topic name.
|
||||
* @param payload - JSON value validated before it reaches the carrier.
|
||||
* @param monotonicMs - Source-clock timestamp; defaults to `performance.now()`.
|
||||
*/
|
||||
publish(topic: string, payload: InspectorJsonValue, monotonicMs?: number): void
|
||||
```
|
||||
|
||||
Source: [`packages/experimental/inspector/src/index.ts`](../../packages/experimental/inspector/src/index.ts)
|
||||
|
||||
<a id="cordis-events"></a>
|
||||
|
||||
### `cordis/*` events
|
||||
|
||||
@@ -256,6 +256,24 @@ Types: [Agent](core.zh.md)
|
||||
|
||||
Source: [`packages/extensions/cordis-host-runner/src/index.ts`](../../packages/extensions/cordis-host-runner/src/index.ts)
|
||||
|
||||
<a id="ctxinspector--inspectorservice"></a>
|
||||
|
||||
### `ctx.inspector` — `InspectorService`
|
||||
|
||||
Shared Host/Client service façade over the realm's source publisher.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Publish one JSON observation without waiting for Worker delivery.
|
||||
* @param topic - Domain-owned topic name.
|
||||
* @param payload - JSON value validated before it reaches the carrier.
|
||||
* @param monotonicMs - Source-clock timestamp; defaults to `performance.now()`.
|
||||
*/
|
||||
publish(topic: string, payload: InspectorJsonValue, monotonicMs?: number): void
|
||||
```
|
||||
|
||||
Source: [`packages/experimental/inspector/src/index.ts`](../../packages/experimental/inspector/src/index.ts)
|
||||
|
||||
<a id="cordis-events"></a>
|
||||
|
||||
### `cordis/*` events
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -147,6 +147,7 @@
|
||||
"release:publish": "tsx scripts/release/publish.ts",
|
||||
"dsh": "node --import tsx/esm apps/cli/src/bin.ts",
|
||||
"demo:code-mode": "node scripts/demo-code-mode.mjs",
|
||||
"demo:inspector": "node --import tsx/esm apps/cli/src/bin.ts web --patch ./packages/experimental/inspector/cordis.patch.yml",
|
||||
"mock:llm": "node --import tsx packages/test-support/llm-mock-server/src/bin.ts",
|
||||
"dev:web": "tsx scripts/dev-web.ts --poll",
|
||||
"postinstall": "node scripts/install-lefthook.mjs"
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/experimental/README.md
|
||||
README.md: 2812388377611ebbed0f415d637bd14bffcee34c
|
||||
README.zh.md: 3dcf13d9ef73b540fe4b8f3d7f6df2e623bca6f4
|
||||
README.md: 750f38a116681a4a57575e9a55a49c06e7b40108
|
||||
README.zh.md: 551ef051a57e2787cea080a3df26c98d2af68f7c
|
||||
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
The experimental group contains prototype capabilities that are not part of any official release: they run on the real harness, but their contracts can change and they carry no support promise. The group holds Agent Teams plus the browser-worker runtime and image packer used by preview deployments. Use these packages to try an unreleased capability; they carry no stability promise, and released products must not depend on them.
|
||||
The experimental group contains prototype capabilities that are not part of any official release: they run on the real harness, but their contracts can change and they carry no support promise. The group holds Agent Teams, the cross-realm Inspector, and the browser-worker runtime and image packer used by preview deployments. Use these packages to try an unreleased capability; they carry no stability promise, and released products must not depend on them.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -28,6 +28,7 @@ The experimental group contains prototype capabilities that are not part of any
|
||||
| [`agent-team`](agent-team/README.md) | Named teammates with durable messages and a shared task board | `ctx.agentTeams` |
|
||||
| [`agent-team-web-profile`](agent-team-web-profile/README.md) | Explicit source-checkout Web layer for Agent Teams | — |
|
||||
| [`client-ui-agent-team`](client-ui-agent-team/README.md) | Team roster, task board, and teammate navigation for Web | — |
|
||||
| [`inspector`](inspector/README.md) | Cross-realm CDP hub for Host debugging, Client Runtime inspection, network capture, and Cordis trees | `ctx.inspector` |
|
||||
| [`tool-agent-team`](tool-agent-team/README.md) | Ten tools that let the model create, message, and coordinate teammates | registers scoped tools on `ctx.tools` |
|
||||
| [`webworker-packer`](webworker-packer/README.md) | Builds the gzip-compressed VFS image consumed by the browser worker preview | library and CLI — no ctx key |
|
||||
| [`webworker-runtime`](webworker-runtime/README.md) | Runs the harness plugin tree inside a dedicated browser worker | library and worker entry — no ctx key |
|
||||
|
||||
@@ -9,7 +9,7 @@ kind: "package-group"
|
||||
|
||||
## 概述
|
||||
|
||||
实验组包含不属于任何正式发布的原型能力:它们运行在真实 harness 上,但约定可能变更,也不提供支持承诺。本组包含 Agent Teams,以及预览部署使用的浏览器 worker 运行时与镜像打包器。用这些包来尝试未发布的能力;它们没有稳定性承诺,已发布产品不得依赖它们。
|
||||
实验组包含不属于任何正式发布的原型能力:它们运行在真实 harness 上,但约定可能变更,也不提供支持承诺。本组包含 Agent Teams、跨 realm Inspector,以及预览部署使用的浏览器 worker 运行时与镜像打包器。用这些包来尝试未发布的能力;它们没有稳定性承诺,已发布产品不得依赖它们。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -28,6 +28,7 @@ kind: "package-group"
|
||||
| [`agent-team`](agent-team/README.zh.md) | 具名 teammate,成员之间持久消息与共享任务板 | `ctx.agentTeams` |
|
||||
| [`agent-team-web-profile`](agent-team-web-profile/README.zh.md) | Agent Teams 的显式源码 checkout Web 层 | — |
|
||||
| [`client-ui-agent-team`](client-ui-agent-team/README.zh.md) | Web Team roster、任务板与 teammate 导航 | — |
|
||||
| [`inspector`](inspector/README.zh.md) | 用于 Host 调试、Client Runtime 检查、网络采集与 Cordis 树的跨 realm CDP hub | `ctx.inspector` |
|
||||
| [`tool-agent-team`](tool-agent-team/README.zh.md) | 让模型创建、发消息与协调 teammate 的十个工具 | 按作用域注册工具到 `ctx.tools` |
|
||||
| [`webworker-packer`](webworker-packer/README.zh.md) | 构建浏览器 worker 预览所消费的 gzip 压缩 VFS 镜像 | 库与 CLI,不使用 ctx key |
|
||||
| [`webworker-runtime`](webworker-runtime/README.zh.md) | 在专用浏览器 worker 中运行 harness 插件树 | 库与 worker 入口,不使用 ctx key |
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/experimental/inspector/README.md
|
||||
README.md: e10a68eed10c0bc71d26b5eacf9ee3eac4c6818d
|
||||
README.zh.md: f6aceb5374730ff9879151980c60e54dad39fd89
|
||||
@@ -0,0 +1,153 @@
|
||||
---
|
||||
description: "Experimental Chrome DevTools inspection for Host and browser Client Cordis runtimes, including Console evaluation, Sources, Network capture, Elements trees, and a CDP-independent query API."
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
# @deepseek-ai/dsh-experimental-inspector
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Use this experimental inspector to inspect one running dsh Host and its browser Clients in Chrome DevTools. It exposes Host and Client Console contexts, Host Sources and debugging, captured Host fetches, and a shared Cordis tree while keeping all CDP state in a Worker.
|
||||
|
||||
The package is private and excluded from releases. The Worker never accesses live Cordis objects: the shared Host/Client collector projects them into validated snapshots before transport. Cordis also owns plugin composition, `ctx.inspector` registration, bootstrap injection, and disposal.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Runtime layout](#runtime-layout)
|
||||
- [Configuration](#configuration)
|
||||
- [Observation API](#observation-api)
|
||||
- [Cordis tree inspection](#cordis-tree-inspection)
|
||||
- [Host fetch capture](#host-fetch-capture)
|
||||
- [Security](#security)
|
||||
- [Model Experience](#model-experience)
|
||||
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
|
||||
- [Dev Note](#dev-note)
|
||||
|
||||
-----
|
||||
|
||||
<a id="runtime-layout"></a>
|
||||
## Runtime layout
|
||||
|
||||
The Host plugin starts the Worker and connects a dedicated `MessagePort`. The Client plugin reads the injected `globalThis.__DSH_INSPECTOR__` bootstrap and opens a separate authenticated WebSocket directly to the Worker. Chrome DevTools connects to the Worker's CDP WebSocket. A private `node:inspector.Session` per DevTools connection attaches from the Worker to the Host main thread, so Host Console evaluation, Sources, breakpoints, and resume remain available while Host JavaScript is paused.
|
||||
|
||||
The source tree follows those execution environments: `client/` and `host/` provide mirrored adapter entry paths, `worker/` contains only Worker-thread orchestration and Chrome protocol state, and `shared/` contains environment-independent Cordis and network models, normalized realm backend interfaces, and the internal bridge protocol. Worker-side Client and Host adapters are mirrored under `worker/realms/`; a Client adapter in that directory still executes in the Worker.
|
||||
|
||||
Host and Client producers send internal observation records rather than CDP messages. Records contain a source generation, sequence, source-clock timestamp, topic, and JSON payload. The Worker validates every process or network frame, owns source state and retention, and translates recognized topics to standard CDP domains.
|
||||
|
||||
Client sources declare typed Runtime, Console, and read-only Sources capabilities. `Runtime.enable` publishes the real Host execution context and one synthetic context for every connected Client source. Selecting a Client context routes evaluation, property access, function calls, promise awaiting, and object release to that browser realm. Client Console arguments use the same session-local object table, while `Debugger.enable` publishes the built `lib/client.js` catalog and `Debugger.getScriptSource` reads bounded content chunks. Client-script breakpoints, step, and call frames remain unsupported; target-wide pause and resume control the Host debugger only.
|
||||
|
||||
Both plugin faces run the same browser-safe Cordis collector. It converts reachable Context and Fiber objects into a versioned `CordisTreeSnapshot`; the Worker stores that CDP-independent representation and projects each Host or Client source into the Elements panel.
|
||||
|
||||
<a id="configuration"></a>
|
||||
## Configuration
|
||||
|
||||
The Host plugin injects `webServer` and accepts these fields:
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---:|---|
|
||||
| `host` | `127.0.0.1` | Worker endpoint bind address; only loopback is accepted |
|
||||
| `port` | `9230` | First Worker endpoint port; occupied ports advance upward, while `0` requests an OS-assigned port |
|
||||
| `clientOrigins` | `[]` | Additional exact browser origins accepted by `/ingest`; loopback origins remain accepted |
|
||||
| `captureFetch` | `true` | Wrap `globalThis.fetch` and publish every later call |
|
||||
| `maxRequestBodyBytes` | 8 MiB | Per-request captured request-body prefix |
|
||||
| `maxResponseBodyBytes` | 32 MiB | Per-request captured response-body prefix |
|
||||
| `maxBodyChunkBytes` | 48 KiB | Raw bytes carried by one body record before base64 encoding |
|
||||
| `maxJournalBytes` | 256 MiB | Worker-retained request and response body bytes |
|
||||
| `maxRetainedRequests` | `2000` | Active and completed requests retained by the Worker |
|
||||
| `maxSourceFrameBytes` | 128 KiB | Encoded source-frame limit |
|
||||
| `maxSourceRecordsPerFrame` | `128` | Records in one source batch |
|
||||
| `maxQueuedRecords` | `2048` | Per-producer records waiting for transport |
|
||||
| `maxQueuedBytes` | 16 MiB | Per-producer queued encoded bytes |
|
||||
| `startupTimeoutMs` | 10 seconds | Worker readiness deadline |
|
||||
| `stopTimeoutMs` | 5 seconds | Graceful Worker shutdown deadline before termination |
|
||||
| `clientReconnectBaseMs` | 250 ms | First Client reconnect backoff cap |
|
||||
| `clientReconnectMaxMs` | 5 seconds | Maximum Client reconnect backoff cap |
|
||||
| `clientRuntimeTimeoutMs` | 30 seconds | Deadline for one Worker-to-Client Runtime or Sources command |
|
||||
| `queryTimeoutMs` | 10 seconds | Deadline for one non-CDP semantic query |
|
||||
| `maxClientRuntimeObjects` | `10000` | Live Client object handles retained per DevTools connection |
|
||||
| `maxClientRuntimeProperties` | `2000` | Property descriptors returned by one Client object inspection |
|
||||
| `maxClientSourceBytes` | 8 MiB | Maximum encoded bytes read from one Client script or source map |
|
||||
| `maxCordisNodes` | `2048` | Context and Fiber nodes admitted from one realm snapshot before truncation |
|
||||
| `maxDisconnectedCordisTrees` | `8` | Last disconnected realm trees retained as non-live snapshots |
|
||||
|
||||
The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-inspector) is the exhaustive source for accepted fields and their declarations.
|
||||
|
||||
The Host logs a `devtools://` URL after the Worker listens. The same Worker serves `/json`, `/json/list`, `/json/version`, the target WebSocket under `/devtools/page/<id>`, and the Client source at `/ingest`.
|
||||
|
||||
<a id="observation-api"></a>
|
||||
## Observation API
|
||||
|
||||
Both plugin faces provide the same service:
|
||||
|
||||
```ts
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InspectorJsonValue } from '@deepseek-ai/dsh-experimental-inspector'
|
||||
|
||||
declare const ctx: Context
|
||||
declare const topic: string
|
||||
declare const jsonPayload: InspectorJsonValue
|
||||
|
||||
ctx.inspector.publish(topic, jsonPayload)
|
||||
await ctx.inspector.cordis.getTree()
|
||||
```
|
||||
|
||||
Publishing validates lossless JSON and schedules delivery without waiting for the Worker. Each source has a bounded queue. Overflow is reported as a sequence gap and never delays the observed application operation. `cordis.getTree()` reads the Worker's latest detached semantic snapshot without creating a CDP session or enabling Runtime, Debugger, or Sources.
|
||||
|
||||
<a id="cordis-tree-inspection"></a>
|
||||
## Cordis tree inspection
|
||||
|
||||
The Elements document has fixed `<host>` and `<clients>` containers. `<host>` contains the Host root Context; `<clients>` contains one `<client>` per Client source, and each `<client>` contains that realm's root Context. The Cordis root Fiber is omitted. Every other Fiber is a child of `fiber.parent`, owns exactly one Context child for `fiber.ctx`, and carries only `uid="<Cordis Fiber.uid>"`; Context elements have no attributes. Context-only `extend()`, `isolate()`, and `intercept()` layers remain direct Context descendants.
|
||||
|
||||
Host and Client publish the same nested `CordisTreeSnapshot` type. Context and Fiber nodes carry opaque object handles for realm-local object lookup; Fiber nodes additionally carry Cordis `uid`. The Worker composes those realm snapshots into one `{ host, clients }` inspection tree. It assigns `BackendNodeId` values per source generation; each DevTools connection assigns its own `NodeId` values; `DOM.resolveNode` asks the owning Host or Client Runtime for a connection-local `RemoteObjectId`. `DOM.requestNode` maps that object id back to the same Elements node. `ctx.inspector.cordis.getTree()` and `DSHInspector.getCordisTree` read the detached consumer-neutral tree without routing handles or CDP ids.
|
||||
|
||||
Node delivery is depth-limited per DevTools connection: `DOM.getDocument` serves three document levels when the caller omits `depth`, withheld levels advertise `childNodeCount`, and expansion fetches them through `DOM.requestChildNodes` (`depth: -1` for a whole subtree). NodeIds leaving through `DOM.performSearch`, `DOM.requestNode`, or `DOM.pushNodesByBackendIdsToFrontend` first push the not-yet-sent ancestor levels as `DOM.setChildNodes` events.
|
||||
|
||||
Sources publish complete snapshots, while the Worker compares stable backend node identities before notifying DevTools. Unchanged snapshots emit no DOM event; additions, removals, and attribute changes use node-level CDP events, inserted-node payloads withhold their subtree, and sibling reordering replaces only that parent's children. Existing `NodeId` values and unaffected Elements expansion remain stable.
|
||||
|
||||
When a Client disconnects, its Console execution context and live object ids are destroyed immediately. With disconnected-tree retention enabled, Elements keeps the last tree unchanged while connection state remains in the inspection model rather than becoming an unreviewed DOM attribute. Reconnection keeps the logical source id, creates a new synthetic CDP context id for the new transport generation, and replaces the stale tree after its complete snapshot arrives. The Worker retains at most `maxDisconnectedCordisTrees` such snapshots; zero removes them immediately.
|
||||
|
||||
<a id="host-fetch-capture"></a>
|
||||
## Host fetch capture
|
||||
|
||||
Fetch capture is on by default and records the complete URL, all request and response headers, request body, response body, status, timing, errors, and cancellation. It does not redact credentials, cookies, query values, or payloads. Body capture reads clones; the caller receives the original Response as soon as the original fetch resolves.
|
||||
|
||||
The configured body limits bound retention rather than select fields: capture keeps the prefix and marks the result truncated. `Network.getRequestPostData` and `Network.getResponseBody` read the Worker's retained bytes. `Network.streamResourceContent` returns the buffered prefix and adds later response bytes to `Network.dataReceived` for that DevTools connection, which drives live Response and EventStream views. Direct Undici Client/Dispatcher calls and fetch references retained before plugin activation are outside this observer.
|
||||
|
||||
After response headers arrive, a caller-side abort can stop the observer's clone; captured bytes remain available through `Network.getResponseBody`, capture metadata records the error and truncation, and CDP emits `Network.loadingFinished` because fetch returned a Response. A fetch rejection before response headers emits `Network.loadingFailed`, with `canceled: true` for an abort.
|
||||
|
||||
<a id="security"></a>
|
||||
## Security
|
||||
|
||||
The CDP target grants arbitrary code execution in both Host and connected Client realms through `Runtime.evaluate`; Host Debugger operations provide additional control. Full fetch capture includes secrets. The Worker therefore accepts only a `127.0.0.1` bind address. Client ingest additionally requires a random WebSocket subprotocol token injected by the Host and rejects non-loopback origins unless explicitly configured. The CDP socket itself has no token; loopback binding is its only access control.
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## Model Experience
|
||||
|
||||
None, as this developer-only inspector observes runtime activity without changing model requests.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- **Client active debugging is unsupported** — Console events, Runtime evaluation, RemoteObject access, and read-only `lib/client.js` Sources work. Client-script debugger requests return explicit unsupported errors; target-wide pause and resume control the Host only.
|
||||
- **Client Sources expose the Inspector bundle only** — other page scripts are not cataloged by this package.
|
||||
- **Client evaluation uses page JavaScript** — page Content Security Policy can block dynamic evaluation, and the synthetic context does not provide DevTools command-line helpers or native REPL declaration semantics.
|
||||
- **Fetch interception covers `globalThis.fetch`** — direct Undici APIs and fetch references retained before activation are not observed.
|
||||
- **Body cloning has cost** — full capture tees request and response streams up to the configured limits and can increase memory and I/O pressure. The retained-body limit does not include buffering inside the stream tee, including an oversized source chunk or data queued for a slower application reader.
|
||||
- **No automatic Worker restart** — an unexpected Worker exit fails the current Inspector instance; lifecycle recovery belongs to a later change.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
<details>
|
||||
<summary>Working context for maintainers — click to expand</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,153 @@
|
||||
---
|
||||
description: "面向 Host 与浏览器 Client Cordis 运行时的实验性 Chrome DevTools 检查,包括 Console 求值、Sources、Network 采集、Elements 树和独立于 CDP 的查询 API。"
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
# @deepseek-ai/dsh-experimental-inspector
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
## 概述
|
||||
|
||||
使用这个实验性 Inspector,可以在 Chrome DevTools 中检查一个运行中的 dsh Host 及其浏览器 Client。它提供 Host 与 Client Console context、Host Sources 与调试、Host fetch 采集和共享 Cordis 树,并让 Worker 独占全部 CDP 状态。
|
||||
|
||||
本包为私有包,不进入正式发布。Worker 不访问实时 Cordis 对象;共享 Host/Client collector 会在传输前把它们投影成已验证 snapshot。Cordis 还负责插件组合、注册 `ctx.inspector`、注入 bootstrap 和资源释放。
|
||||
|
||||
## 目录
|
||||
|
||||
- [运行时布局](#runtime-layout)
|
||||
- [配置](#configuration)
|
||||
- [观测 API](#observation-api)
|
||||
- [Cordis 树检查](#cordis-tree-inspection)
|
||||
- [Host fetch 采集](#host-fetch-capture)
|
||||
- [安全](#security)
|
||||
- [模型体验](#model-experience)
|
||||
- [已知限制与延期工作](#known-limitations-and-deferred-work)
|
||||
- [开发备注](#dev-note)
|
||||
|
||||
-----
|
||||
|
||||
<a id="runtime-layout"></a>
|
||||
## 运行时布局
|
||||
|
||||
Host 插件启动 Worker 并连接专用 `MessagePort`。Client 插件读取注入的 `globalThis.__DSH_INSPECTOR__` bootstrap,直接向 Worker 打开一条独立、带鉴权的 WebSocket。Chrome DevTools 连接 Worker 的 CDP WebSocket。每条 DevTools 连接在 Worker 中独占一个连接 Host 主线程的 `node:inspector.Session`,因此 Host JavaScript 暂停时,Host Console 求值、Sources、断点和 resume 仍然可用。
|
||||
|
||||
源码树遵循这些执行环境:`client/` 与 `host/` 提供镜像的 adapter entry path,`worker/` 只包含 Worker thread orchestration 与 Chrome protocol 状态,`shared/` 包含与环境无关的 Cordis 和 network model、规范化 realm backend interface 及内部 bridge protocol。Worker 侧 Client 与 Host adapter 镜像放在 `worker/realms/` 下;其中的 Client adapter 仍然在 Worker 中执行。
|
||||
|
||||
Host 与 Client producer 发送内部观测记录,不发送 CDP 消息。记录包含 source generation、sequence、source 时钟时间、topic 和 JSON payload。Worker 验证每个进程或网络帧,独占 source 状态与保留历史,并把已识别 topic 转换成标准 CDP domain。
|
||||
|
||||
Client source 声明类型化 Runtime、Console 和只读 Sources 能力。`Runtime.enable` 发布真实 Host execution context,并为每个已连接的 Client source 发布一个 synthetic context。选择 Client context 后,求值、属性读取、函数调用、Promise await 和对象释放都会路由到该浏览器 realm。Client Console argument 使用同一份 session-local object table;`Debugger.enable` 发布构建后的 `lib/client.js` catalog,`Debugger.getScriptSource` 读取有界 content chunk。Client script 断点、step 和 call frame 仍不支持;target-wide pause 与 resume 只控制 Host debugger。
|
||||
|
||||
两个插件面运行同一份浏览器安全 Cordis collector。它把可达 Context 与 Fiber 对象转换成有版本的 `CordisTreeSnapshot`;Worker 存储这份与 CDP 无关的表示,并把每个 Host 或 Client source 投影到 Elements 面板。
|
||||
|
||||
<a id="configuration"></a>
|
||||
## 配置
|
||||
|
||||
Host 插件注入 `webServer`,接受以下字段:
|
||||
|
||||
| 字段 | 默认值 | 含义 |
|
||||
|---|---:|---|
|
||||
| `host` | `127.0.0.1` | Worker endpoint 监听地址;只接受 loopback |
|
||||
| `port` | `9230` | Worker endpoint 起始端口;端口占用时向上递增,`0` 表示由操作系统分配 |
|
||||
| `clientOrigins` | `[]` | `/ingest` 额外接受的精确浏览器 origin;loopback origin 始终允许 |
|
||||
| `captureFetch` | `true` | 包装 `globalThis.fetch` 并发布之后的每次调用 |
|
||||
| `maxRequestBodyBytes` | 8 MiB | 每次请求保留的 request body 前缀 |
|
||||
| `maxResponseBodyBytes` | 32 MiB | 每次请求保留的 response body 前缀 |
|
||||
| `maxBodyChunkBytes` | 48 KiB | base64 编码前一条 body 记录携带的原始字节数 |
|
||||
| `maxJournalBytes` | 256 MiB | Worker 保留的请求与响应 body 总字节数 |
|
||||
| `maxRetainedRequests` | `2000` | Worker 保留的进行中与已完成请求总数 |
|
||||
| `maxSourceFrameBytes` | 128 KiB | 编码后的 source frame 上限 |
|
||||
| `maxSourceRecordsPerFrame` | `128` | 每个 source batch 的记录数 |
|
||||
| `maxQueuedRecords` | `2048` | 每个 producer 等待发送的记录数 |
|
||||
| `maxQueuedBytes` | 16 MiB | 每个 producer 等待发送的编码字节数 |
|
||||
| `startupTimeoutMs` | 10 秒 | Worker ready 截止时间 |
|
||||
| `stopTimeoutMs` | 5 秒 | 强制终止前的 Worker 优雅关闭期限 |
|
||||
| `clientReconnectBaseMs` | 250 ms | Client 首次重连退避上限 |
|
||||
| `clientReconnectMaxMs` | 5 秒 | Client 最大重连退避上限 |
|
||||
| `clientRuntimeTimeoutMs` | 30 秒 | 一次 Worker 到 Client Runtime 或 Sources 命令的截止时间 |
|
||||
| `queryTimeoutMs` | 10 秒 | 一次非 CDP 语义查询的截止时间 |
|
||||
| `maxClientRuntimeObjects` | `10000` | 每条 DevTools 连接保留的 Client 实时对象 handle 数 |
|
||||
| `maxClientRuntimeProperties` | `2000` | 单次 Client 对象检查返回的属性描述符数 |
|
||||
| `maxClientSourceBytes` | 8 MiB | 单个 Client script 或 source map 允许读取的最大编码字节数 |
|
||||
| `maxCordisNodes` | `2048` | 一个 realm snapshot 截断前允许的 Context 与 Fiber 节点数 |
|
||||
| `maxDisconnectedCordisTrees` | `8` | 作为非实时 snapshot 保留的最近断联 realm 树数量 |
|
||||
|
||||
生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-inspector)是全部已接受字段及其声明的详尽来源。
|
||||
|
||||
Worker 监听后,Host 会记录一个 `devtools://` URL。同一个 Worker 提供 `/json`、`/json/list`、`/json/version`、`/devtools/page/<id>` target WebSocket 和 `/ingest` Client source。
|
||||
|
||||
<a id="observation-api"></a>
|
||||
## 观测 API
|
||||
|
||||
两个插件面都提供同一个服务:
|
||||
|
||||
```ts
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InspectorJsonValue } from '@deepseek-ai/dsh-experimental-inspector'
|
||||
|
||||
declare const ctx: Context
|
||||
declare const topic: string
|
||||
declare const jsonPayload: InspectorJsonValue
|
||||
|
||||
ctx.inspector.publish(topic, jsonPayload)
|
||||
await ctx.inspector.cordis.getTree()
|
||||
```
|
||||
|
||||
发布操作先验证无损 JSON,再调度发送,不等待 Worker。每个 source 的队列都有上限;溢出表现为 sequence gap,绝不延迟被观察的应用操作。`cordis.getTree()` 读取 Worker 最新的 detached semantic snapshot,不创建 CDP session,也不启用 Runtime、Debugger 或 Sources。
|
||||
|
||||
<a id="cordis-tree-inspection"></a>
|
||||
## Cordis tree inspection
|
||||
|
||||
Elements document 包含固定的 `<host>` 与 `<clients>` 容器。`<host>` 包含 Host root Context;`<clients>` 为每个 Client source 包含一个 `<client>`,每个 `<client>` 再包含该 realm 的 root Context。Cordis root Fiber 不显示。其他 Fiber 都是 `fiber.parent` 的子节点,并包含唯一一个表示 `fiber.ctx` 的 Context 子节点;Fiber 只携带 `uid="<Cordis Fiber.uid>"`,Context element 不携带 attribute。只有 Context 的 `extend()`、`isolate()` 与 `intercept()` 层仍然是直接 Context 后代。
|
||||
|
||||
Host 与 Client 发布同一种嵌套 `CordisTreeSnapshot` 类型。Context 与 Fiber 节点携带用于 realm-local 对象查询的不透明 object handle;Fiber 还携带 Cordis `uid`。Worker 把这些 realm snapshot 组合成一棵 `{ host, clients }` inspection tree。Worker 按 source generation 分配 `BackendNodeId`;每条 DevTools 连接分配自己的 `NodeId`;`DOM.resolveNode` 请求所属 Host 或 Client Runtime 生成连接本地 `RemoteObjectId`。`DOM.requestNode` 把该 object id 映射回同一个 Elements 节点。`ctx.inspector.cordis.getTree()` 与 `DSHInspector.getCordisTree` 读取不含 routing handle 或 CDP id 的 detached consumer-neutral tree。
|
||||
|
||||
节点按 DevTools 连接做深度受限下发:调用方省略 `depth` 时 `DOM.getDocument` 提供三层 document,被扣留的层级通过 `childNodeCount` 声明数量,展开时经 `DOM.requestChildNodes` 获取(`depth: -1` 取整棵子树)。经 `DOM.performSearch`、`DOM.requestNode` 或 `DOM.pushNodesByBackendIdsToFrontend` 流出的 NodeId 会先把尚未下发的祖先层级以 `DOM.setChildNodes` event 推送出去。
|
||||
|
||||
source 仍发布完整 snapshot,Worker 在通知 DevTools 前按稳定的 backend node identity 比较差异。无变化的 snapshot 不发送 DOM event;新增、移除和 attribute 变化使用节点级 CDP event,插入节点的载荷扣留其子树,兄弟节点重排只替换对应 parent 的 children。现有 `NodeId` 与未受影响的 Elements 展开状态保持稳定。
|
||||
|
||||
Client 断联时,其 Console execution context 与 live object id 会立即销毁。启用断联树保留后,Elements 会原样保留最后一棵树;连接状态留在 inspection model 中,不会未经设计就成为 DOM attribute。重连会沿用逻辑 source id,为新的 transport generation 创建新的 synthetic CDP context id,并在完整 snapshot 到达后替换旧树。Worker 最多保留 `maxDisconnectedCordisTrees` 棵此类 snapshot;设为零会立即移除。
|
||||
|
||||
<a id="host-fetch-capture"></a>
|
||||
## Host fetch 采集
|
||||
|
||||
fetch 采集默认开启,记录完整 URL、全部请求与响应 headers、请求体、响应体、状态、时间、错误和取消。它不脱敏 credential、Cookie、query value 或 payload。body 采集读取 clone;原始 fetch resolve 后,调用方立即拿到原始 Response。
|
||||
|
||||
配置的 body 上限限制保留量,而不选择字段:采集保留前缀并标记 truncated。`Network.getRequestPostData` 与 `Network.getResponseBody` 读取 Worker 保留的字节。`Network.streamResourceContent` 返回已缓冲的前缀,并仅为发起调用的 DevTools 连接把后续 response 字节附加到 `Network.dataReceived`,以驱动实时 Response 与 EventStream 视图。直接调用 Undici Client/Dispatcher,以及插件激活前保存的 fetch 引用,不在观察范围内。
|
||||
|
||||
response headers 到达后,调用方 abort 可能会终止 observer clone;已采集的字节仍可通过 `Network.getResponseBody` 读取,采集 metadata 记录错误与截断,并且 CDP 因 fetch 已返回 Response 而发送 `Network.loadingFinished`。response headers 到达前发生的 fetch rejection 会发送 `Network.loadingFailed`,其中 abort 对应 `canceled: true`。
|
||||
|
||||
<a id="security"></a>
|
||||
## 安全
|
||||
|
||||
CDP target 通过 `Runtime.evaluate` 提供 Host 和已连接 Client realm 中的任意代码执行能力,Host Debugger 操作还会提供额外控制,完整 fetch 采集也包含秘密。因此 Worker 只接受 `127.0.0.1` 监听地址。Client ingest 还要求 Host 注入的随机 WebSocket subprotocol token;除非配置明确允许,否则拒绝非 loopback origin。CDP socket 本身不携带 token,loopback 监听是它唯一的访问控制。
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
无:这个仅供开发者使用的 Inspector 只观察运行时活动,不改变模型请求。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无:本包既不组装也不发送 provider 请求。
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- **Client active debugging 不受支持**——Console event、Runtime 求值、RemoteObject 访问和只读 `lib/client.js` Sources 可用。Client script debugger request 返回明确的 unsupported error;target-wide pause 与 resume 只控制 Host。
|
||||
- **Client Sources 只暴露 Inspector bundle**——本包不收录页面中的其他 script。
|
||||
- **Client 求值使用页面 JavaScript**——页面 Content Security Policy 可能阻止动态求值;synthetic context 不提供 DevTools command-line helper 或原生 REPL 声明语义。
|
||||
- **fetch 拦截范围是 `globalThis.fetch`**——直接调用 Undici API,以及激活前保存的 fetch 引用不会被观察。
|
||||
- **body clone 有运行成本**——完整采集会 tee 请求与响应 stream,直至达到配置上限,可能增加内存与 I/O 压力。保留 body 的上限不包含 stream tee 内部的缓冲,包括来源提供的超大 chunk,或为读取较慢的应用分支排队的数据。
|
||||
- **不自动重启 Worker**——Worker 意外退出会使当前 Inspector 实例失败;生命周期恢复留待后续改动。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
<details>
|
||||
<summary>维护者的工作上下文——点击展开</summary>
|
||||
|
||||
无。
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,13 @@
|
||||
# Development overlay for the experimental inspector: mount it per launch with
|
||||
# pnpm run demo:inspector (pnpm dsh web --patch ./packages/experimental/inspector/cordis.patch.yml)
|
||||
# A source launch resolves this workspace package through the tsconfig paths
|
||||
# facade and needs no installation. A built launch additionally needs the
|
||||
# package importable from the profile:
|
||||
# dsh plugin --profile web add link:<absolute path to this package directory>
|
||||
# (`link:`, not `file:` — `file:` re-installs the workspace:^ dependencies
|
||||
# inside the profile and fails). The package is private and ships with no
|
||||
# published dsh installation; a missing package fails loud at entry import.
|
||||
|
||||
- insert:
|
||||
- id: experimental-inspector
|
||||
name: '@deepseek-ai/dsh-experimental-inspector'
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-experimental-inspector",
|
||||
"description": "Experimental cross-realm CDP hub for Host debugging and Client Runtime inspection",
|
||||
"version": "0.1.1-rc.2",
|
||||
"private": true,
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/experimental/inspector"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-crypto": "workspace:^",
|
||||
"@deepseek-ai/schemastery": "workspace:^",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-modules": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/cordis-plugin-include": "workspace:^",
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/ws": "^8.18.1",
|
||||
"playwright": "^1.49.0",
|
||||
"tsx": "^4.19.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/** Browser Client bridge construction for the Cordis plugin entry. */
|
||||
|
||||
import type { InspectorClientBootstrap } from '../../shared/bridge/messages/control.ts'
|
||||
import { ClientInspectorSource } from './transport.ts'
|
||||
|
||||
/**
|
||||
* Start the browser source transport for one validated Host bootstrap.
|
||||
* @param bootstrap - Host-injected endpoint and resource limits.
|
||||
* @returns The active reconnecting Client source.
|
||||
*/
|
||||
export function startInspectorClient(bootstrap: InspectorClientBootstrap): ClientInspectorSource {
|
||||
return new ClientInspectorSource(bootstrap)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/** Dispatch of validated Worker frames to browser-realm capability handlers. */
|
||||
|
||||
import type {
|
||||
ClientConsoleDisableFrame,
|
||||
ClientConsoleEnableFrame,
|
||||
ClientRuntimeCancelFrame,
|
||||
ClientRuntimeRequestFrame,
|
||||
ClientRuntimeResponseAcknowledgedFrame,
|
||||
ClientRuntimeSessionClosedFrame,
|
||||
} from '../../shared/bridge/messages/runtime/index.ts'
|
||||
import type { ClientSourceRequestFrame, ClientSourceSessionClosedFrame } from '../../shared/bridge/messages/sources/index.ts'
|
||||
import type {
|
||||
SourceAcceptedFrame,
|
||||
SourceAppendAcknowledgedFrame,
|
||||
SourceRejectedFrame,
|
||||
SourceResnapshotFrame,
|
||||
WorkerToSourceFrame,
|
||||
} from '../../shared/bridge/messages/observation.ts'
|
||||
|
||||
/** Operations invoked for each Worker-to-Client frame family. */
|
||||
export interface ClientBridgeFrameHandlers {
|
||||
accepted(frame: SourceAcceptedFrame): void
|
||||
acknowledged(frame: SourceAppendAcknowledgedFrame): void
|
||||
resnapshot(frame: SourceResnapshotFrame): void
|
||||
rejected(frame: SourceRejectedFrame): void
|
||||
runtime(frame: ClientRuntimeRequestFrame): void
|
||||
runtimeCanceled(frame: ClientRuntimeCancelFrame): void
|
||||
runtimeAcknowledged(frame: ClientRuntimeResponseAcknowledgedFrame): void
|
||||
runtimeClosed(frame: ClientRuntimeSessionClosedFrame): void
|
||||
consoleEnabled(frame: ClientConsoleEnableFrame): void
|
||||
consoleDisabled(frame: ClientConsoleDisableFrame): void
|
||||
sources(frame: ClientSourceRequestFrame): void
|
||||
sourcesClosed(frame: ClientSourceSessionClosedFrame): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch one validated Worker frame without exposing transport details to domain adapters.
|
||||
* @param frame - Decoded Worker-to-source frame.
|
||||
* @param handlers - Browser-realm operations for each frame family.
|
||||
*/
|
||||
export function dispatchBridgeFrame(frame: WorkerToSourceFrame, handlers: ClientBridgeFrameHandlers): void {
|
||||
switch (frame.t) {
|
||||
case 'source/accepted':
|
||||
handlers.accepted(frame)
|
||||
return
|
||||
case 'source/append-acknowledged':
|
||||
handlers.acknowledged(frame)
|
||||
return
|
||||
case 'source/resnapshot':
|
||||
handlers.resnapshot(frame)
|
||||
return
|
||||
case 'source/rejected':
|
||||
handlers.rejected(frame)
|
||||
return
|
||||
case 'client-runtime/request':
|
||||
handlers.runtime(frame)
|
||||
return
|
||||
case 'client-runtime/cancel':
|
||||
handlers.runtimeCanceled(frame)
|
||||
return
|
||||
case 'client-runtime/response-acknowledged':
|
||||
handlers.runtimeAcknowledged(frame)
|
||||
return
|
||||
case 'client-runtime/session-closed':
|
||||
handlers.runtimeClosed(frame)
|
||||
return
|
||||
case 'client-console/enable':
|
||||
handlers.consoleEnabled(frame)
|
||||
return
|
||||
case 'client-console/disable':
|
||||
handlers.consoleDisabled(frame)
|
||||
return
|
||||
case 'client-sources/request':
|
||||
handlers.sources(frame)
|
||||
return
|
||||
case 'client-sources/session-closed':
|
||||
handlers.sourcesClosed(frame)
|
||||
return
|
||||
default:
|
||||
return assertNever(frame)
|
||||
}
|
||||
}
|
||||
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`Unexpected Worker source frame: ${JSON.stringify(value)}`)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/** Reconnection lifecycle for the browser Client bridge. */
|
||||
|
||||
/** Owns one bounded-backoff timer and prevents reconnection after disposal. */
|
||||
export class ClientBridgeLifecycle {
|
||||
private reconnectAttempt = 0
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | undefined
|
||||
private closed = false
|
||||
|
||||
constructor(
|
||||
private readonly baseDelayMs: number,
|
||||
private readonly maxDelayMs: number,
|
||||
) {}
|
||||
|
||||
/** Reset backoff after the Worker accepts a source generation. */
|
||||
connected(): void {
|
||||
this.reconnectAttempt = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule the next reconnect attempt unless one is already pending.
|
||||
* @param connect - Operation that opens the next transport generation.
|
||||
*/
|
||||
reconnect(connect: () => void): void {
|
||||
if (this.reconnectTimer !== undefined || this.closed) return
|
||||
const cap = Math.min(this.maxDelayMs, this.baseDelayMs * 2 ** this.reconnectAttempt)
|
||||
this.reconnectAttempt++
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = undefined
|
||||
connect()
|
||||
}, cap / 2 + Math.random() * cap / 2)
|
||||
}
|
||||
|
||||
/** Stop pending and future reconnect attempts. */
|
||||
close(): void {
|
||||
if (this.closed) return
|
||||
this.closed = true
|
||||
if (this.reconnectTimer !== undefined) clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/** Buffered Client observation publication across reconnecting WebSockets. */
|
||||
|
||||
import { InspectorSourceBuffer, type InspectorSourceBufferOptions } from '../../shared/bridge/buffer.ts'
|
||||
import type { InspectorJsonValue } from '../../shared/json.ts'
|
||||
import type { InspectorStatePublisher } from '../../shared/bridge/publisher.ts'
|
||||
import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts'
|
||||
|
||||
interface ActivePublication {
|
||||
readonly socket: WebSocket
|
||||
readonly source: InspectorSourceDescriptor
|
||||
accepted: boolean
|
||||
}
|
||||
|
||||
/** Non-blocking Client publisher whose bounded state survives transport reconnects. */
|
||||
export class ClientBridgePublisher implements InspectorStatePublisher {
|
||||
private readonly records: InspectorSourceBuffer
|
||||
private active: ActivePublication | undefined
|
||||
private flushTimer: ReturnType<typeof setTimeout> | undefined
|
||||
private closed = false
|
||||
|
||||
constructor(
|
||||
options: InspectorSourceBufferOptions,
|
||||
private readonly maxBufferedBytes: number,
|
||||
) {
|
||||
this.records = new InspectorSourceBuffer(options)
|
||||
}
|
||||
|
||||
publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()): void {
|
||||
if (this.closed) return
|
||||
this.records.publish(topic, payload, monotonicMs)
|
||||
this.flush()
|
||||
}
|
||||
|
||||
setState(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()): void {
|
||||
if (this.closed) throw new Error('inspector: Client source is closed')
|
||||
this.records.setState(topic, payload, monotonicMs)
|
||||
this.flush()
|
||||
}
|
||||
|
||||
/**
|
||||
* Install one unopened transport generation.
|
||||
* @param socket - WebSocket carrying the generation.
|
||||
* @param source - Source identity and generation sent by the socket.
|
||||
*/
|
||||
connect(socket: WebSocket, source: InspectorSourceDescriptor): void {
|
||||
this.active = { socket, source, accepted: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Send retained state and queued observations after Worker acceptance.
|
||||
* @param socket - Accepted active WebSocket.
|
||||
*/
|
||||
accept(socket: WebSocket): void {
|
||||
const active = this.active
|
||||
if (active?.socket !== socket) return
|
||||
active.accepted = true
|
||||
this.replace(socket)
|
||||
this.flush()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend retained state for the active generation.
|
||||
* @param socket - WebSocket that received the resnapshot request.
|
||||
*/
|
||||
replace(socket: WebSocket): void {
|
||||
const active = this.active
|
||||
if (active?.socket !== socket || socket.readyState !== WebSocket.OPEN) return
|
||||
socket.send(JSON.stringify(this.records.replacement(active.source.sourceId, active.source.generation)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget one closed transport while retaining buffered state for reconnect.
|
||||
* @param socket - WebSocket whose close event fired.
|
||||
*/
|
||||
disconnect(socket: WebSocket): void {
|
||||
if (this.active?.socket === socket) this.active = undefined
|
||||
}
|
||||
|
||||
/** Stop delayed writes and reject later publication. */
|
||||
close(): void {
|
||||
if (this.closed) return
|
||||
this.closed = true
|
||||
this.active = undefined
|
||||
if (this.flushTimer !== undefined) clearTimeout(this.flushTimer)
|
||||
this.flushTimer = undefined
|
||||
}
|
||||
|
||||
private flush(): void {
|
||||
const active = this.active
|
||||
if (!active?.accepted || active.socket.readyState !== WebSocket.OPEN) return
|
||||
if (active.socket.bufferedAmount > this.maxBufferedBytes) {
|
||||
this.scheduleFlush()
|
||||
return
|
||||
}
|
||||
while (this.records.hasPending && active.socket.bufferedAmount <= this.maxBufferedBytes) {
|
||||
const frame = this.records.takeBatch(active.source.sourceId, active.source.generation)
|
||||
if (frame === undefined) break
|
||||
active.socket.send(JSON.stringify(frame))
|
||||
}
|
||||
if (this.records.hasPending) this.scheduleFlush()
|
||||
}
|
||||
|
||||
private scheduleFlush(): void {
|
||||
if (this.flushTimer !== undefined || this.closed) return
|
||||
this.flushTimer = setTimeout(() => {
|
||||
this.flushTimer = undefined
|
||||
this.flush()
|
||||
}, 25)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Client-side non-CDP query bridge over the active Worker WebSocket. */
|
||||
|
||||
import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts'
|
||||
import { InspectorQueryConnection } from '../../shared/bridge/rpc.ts'
|
||||
|
||||
/** Owns query correlation across reconnecting Client source generations. */
|
||||
export class ClientBridgeRpc extends InspectorQueryConnection {
|
||||
/**
|
||||
* Connect query writes to one accepted Client WebSocket generation.
|
||||
* @param source - Accepted source descriptor.
|
||||
* @param socket - Active source WebSocket.
|
||||
*/
|
||||
connectSocket(source: InspectorSourceDescriptor, socket: WebSocket): void {
|
||||
this.connect(source.sourceId, source.generation, {
|
||||
send: (frame) => {
|
||||
if (socket.readyState !== WebSocket.OPEN) throw new Error('Inspector Client query socket is not connected')
|
||||
socket.send(JSON.stringify(frame))
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
/** Client observation and Runtime endpoint over the Inspector Worker's ingest WebSocket. */
|
||||
|
||||
import type { InspectorClientBootstrap } from '../../shared/bridge/messages/control.ts'
|
||||
import type {
|
||||
ClientRuntimeRequestId,
|
||||
ClientRuntimeSessionId,
|
||||
InspectorSourceGeneration,
|
||||
} from '../../shared/bridge/ids.ts'
|
||||
import { isJsonValue, jsonByteLength } from '../../shared/json.ts'
|
||||
import {
|
||||
INSPECTOR_PROTOCOL_VERSION,
|
||||
parseWorkerSourceFrame,
|
||||
type SourceCloseFrame,
|
||||
type SourceOpenFrame,
|
||||
} from '../../shared/bridge/messages/observation.ts'
|
||||
import { InspectorSourceConnection } from '../../shared/bridge/publisher.ts'
|
||||
import { ClientConsoleObserver } from '../cdp/console.ts'
|
||||
import { ClientRuntimeExecutor } from '../cdp/runtime.ts'
|
||||
import {
|
||||
ClientSourceCatalog,
|
||||
ClientSourceCatalogError,
|
||||
discoverInspectorClientSourceCatalog,
|
||||
} from '../cdp/sources.ts'
|
||||
import type { ClientSourceRequestFrame, ClientSourceResponseFrame } from '../../shared/bridge/messages/sources/index.ts'
|
||||
import { ClientRealmSource } from '../inspection/realm.ts'
|
||||
import { NETWORK_TOPICS } from '../inspection/network.ts'
|
||||
import { ClientBridgeLifecycle } from './lifecycle.ts'
|
||||
import { ClientBridgePublisher } from './publisher.ts'
|
||||
import { ClientBridgeRpc } from './rpc.ts'
|
||||
import { dispatchBridgeFrame } from './dispatcher.ts'
|
||||
|
||||
/** Reconnecting Client source whose bounded queue never blocks page work. */
|
||||
export class ClientInspectorSource extends InspectorSourceConnection {
|
||||
private readonly realmSource: ClientRealmSource
|
||||
protected readonly publisher: ClientBridgePublisher
|
||||
private socket: WebSocket | undefined
|
||||
private generation: InspectorSourceGeneration | undefined
|
||||
private accepted = false
|
||||
private closed = false
|
||||
private readonly runtime: ClientRuntimeExecutor
|
||||
private readonly runtimeRequests = new Map<ClientRuntimeRequestId, {
|
||||
readonly controller: AbortController
|
||||
readonly sessionId: ClientRuntimeSessionId
|
||||
}>()
|
||||
private readonly console: ClientConsoleObserver
|
||||
protected readonly queries: ClientBridgeRpc
|
||||
private readonly lifecycle: ClientBridgeLifecycle
|
||||
|
||||
constructor(
|
||||
private readonly bootstrap: InspectorClientBootstrap,
|
||||
label = document.title || 'Client',
|
||||
private readonly sourceCatalog: ClientSourceCatalog | undefined = discoverInspectorClientSourceCatalog(),
|
||||
) {
|
||||
super()
|
||||
this.realmSource = new ClientRealmSource(label)
|
||||
this.lifecycle = new ClientBridgeLifecycle(bootstrap.reconnectBaseMs, bootstrap.reconnectMaxMs)
|
||||
this.publisher = new ClientBridgePublisher({
|
||||
topics: ['*'],
|
||||
maxQueuedRecords: bootstrap.maxQueuedRecords,
|
||||
maxQueuedBytes: bootstrap.maxQueuedBytes,
|
||||
maxRecordsPerFrame: bootstrap.maxRecordsPerFrame,
|
||||
maxFrameBytes: bootstrap.maxFrameBytes,
|
||||
}, bootstrap.maxQueuedBytes)
|
||||
this.runtime = new ClientRuntimeExecutor({
|
||||
maxObjectsPerSession: bootstrap.maxRuntimeObjectsPerSession,
|
||||
maxPropertiesPerResult: bootstrap.maxRuntimePropertiesPerResult,
|
||||
maxResponseBytes: bootstrap.maxFrameBytes,
|
||||
}, url => this.sourceCatalog?.scriptKeyForUrl(url))
|
||||
this.console = new ClientConsoleObserver(this.runtime, (sessionId, event) => {
|
||||
const socket = this.socket
|
||||
const generation = this.generation
|
||||
if (this.closed
|
||||
|| !this.accepted
|
||||
|| socket?.readyState !== WebSocket.OPEN
|
||||
|| generation === undefined) return
|
||||
const frame = {
|
||||
v: INSPECTOR_PROTOCOL_VERSION,
|
||||
t: 'client-console/event',
|
||||
sourceId: this.realmSource.sourceId,
|
||||
generation,
|
||||
sessionId,
|
||||
event,
|
||||
} as const
|
||||
if (!isJsonValue(frame) || jsonByteLength(frame) > this.bootstrap.maxFrameBytes) return
|
||||
try {
|
||||
socket.send(JSON.stringify(frame))
|
||||
} catch {
|
||||
// The socket close path resets this generation's Runtime and Console state.
|
||||
}
|
||||
}, url => this.sourceCatalog?.scriptKeyForUrl(url))
|
||||
this.queries = new ClientBridgeRpc({
|
||||
timeoutMs: bootstrap.queryTimeoutMs,
|
||||
maxFrameBytes: bootstrap.maxFrameBytes,
|
||||
})
|
||||
this.connect()
|
||||
}
|
||||
|
||||
/** Permanently stop reconnecting and close the active source generation. */
|
||||
close(): void {
|
||||
if (this.closed) return
|
||||
this.closed = true
|
||||
this.console.close()
|
||||
this.cancelRuntimeRequests()
|
||||
this.runtime.reset()
|
||||
this.queries.close('Inspector Client source closed')
|
||||
this.lifecycle.close()
|
||||
this.publisher.close()
|
||||
const socket = this.socket
|
||||
const generation = this.generation
|
||||
if (socket?.readyState === WebSocket.OPEN && generation !== undefined) {
|
||||
const frame: SourceCloseFrame = {
|
||||
v: INSPECTOR_PROTOCOL_VERSION,
|
||||
t: 'source/close',
|
||||
sourceId: this.realmSource.sourceId,
|
||||
generation,
|
||||
}
|
||||
socket.send(JSON.stringify(frame))
|
||||
socket.close(1000, 'Client source closed')
|
||||
} else {
|
||||
socket?.close()
|
||||
}
|
||||
this.socket = undefined
|
||||
}
|
||||
|
||||
private connect(): void {
|
||||
if (this.closed) return
|
||||
this.console.reset()
|
||||
this.cancelRuntimeRequests()
|
||||
this.runtime.reset()
|
||||
this.queries.disconnect('Inspector Client source reconnecting')
|
||||
const source = this.realmSource.connect(this.sourceCatalog !== undefined)
|
||||
const generation = source.generation
|
||||
const socket = new WebSocket(this.bootstrap.endpoint, this.bootstrap.protocol)
|
||||
this.socket = socket
|
||||
this.generation = generation
|
||||
this.accepted = false
|
||||
this.publisher.connect(socket, source)
|
||||
socket.addEventListener('open', () => {
|
||||
if (this.socket !== socket || this.closed) return
|
||||
const frame: SourceOpenFrame = {
|
||||
v: INSPECTOR_PROTOCOL_VERSION,
|
||||
t: 'source/open',
|
||||
source,
|
||||
topics: ['*', ...NETWORK_TOPICS],
|
||||
}
|
||||
socket.send(JSON.stringify(frame))
|
||||
})
|
||||
socket.addEventListener('message', (event) => {
|
||||
if (this.socket !== socket || typeof event.data !== 'string') return
|
||||
try {
|
||||
if (new TextEncoder().encode(event.data).byteLength > this.bootstrap.maxFrameBytes) {
|
||||
throw new Error(`inspector protocol: Worker frame exceeds ${String(this.bootstrap.maxFrameBytes)} bytes`)
|
||||
}
|
||||
const value = JSON.parse(event.data) as unknown
|
||||
if (this.queries.receive(value)) return
|
||||
const frame = parseWorkerSourceFrame(value)
|
||||
if (frame.t !== 'source/rejected'
|
||||
&& (frame.sourceId !== this.realmSource.sourceId || frame.generation !== generation)) return
|
||||
dispatchBridgeFrame(frame, {
|
||||
accepted: () => {
|
||||
this.accepted = true
|
||||
this.lifecycle.connected()
|
||||
this.queries.connectSocket(source, socket)
|
||||
this.publisher.accept(socket)
|
||||
},
|
||||
acknowledged: () => {},
|
||||
resnapshot: () => { this.publisher.replace(socket) },
|
||||
rejected: (rejected) => {
|
||||
console.error(`[inspector] Client source rejected: ${rejected.message}`)
|
||||
socket.close(1008, 'source rejected')
|
||||
},
|
||||
runtime: (request) => {
|
||||
void this.executeRuntime(socket, generation, request).catch((error: unknown) => {
|
||||
console.error('[inspector] Client Runtime transport failed:', error)
|
||||
socket.close(1011, 'Client Runtime transport failed')
|
||||
})
|
||||
},
|
||||
runtimeCanceled: (canceled) => { this.cancelRuntime(canceled.sessionId, canceled.requestId) },
|
||||
runtimeAcknowledged: (acknowledged) => {
|
||||
this.acknowledgeRuntime(acknowledged.sessionId, acknowledged.requestId)
|
||||
},
|
||||
runtimeClosed: (closed) => {
|
||||
this.cancelRuntimeSession(closed.sessionId)
|
||||
this.console.disable(closed.sessionId)
|
||||
this.runtime.closeSession(closed.sessionId)
|
||||
},
|
||||
consoleEnabled: (enabled) => { this.console.enable(enabled.sessionId) },
|
||||
consoleDisabled: (disabled) => { this.console.disable(disabled.sessionId) },
|
||||
sources: (request) => {
|
||||
void this.executeSourceRequest(socket, generation, request).catch((error: unknown) => {
|
||||
console.error('[inspector] Client Sources transport failed:', error)
|
||||
socket.close(1011, 'Client Sources transport failed')
|
||||
})
|
||||
},
|
||||
sourcesClosed: () => {},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[inspector] invalid Worker control frame:', error)
|
||||
socket.close(1008, 'invalid Worker control frame')
|
||||
}
|
||||
})
|
||||
socket.addEventListener('close', () => {
|
||||
if (this.socket !== socket || this.closed) return
|
||||
this.socket = undefined
|
||||
this.accepted = false
|
||||
this.publisher.disconnect(socket)
|
||||
this.console.reset()
|
||||
this.cancelRuntimeRequests()
|
||||
this.runtime.reset()
|
||||
this.queries.disconnect('Inspector Client source disconnected')
|
||||
this.lifecycle.reconnect(() => { this.connect() })
|
||||
})
|
||||
socket.addEventListener('error', () => {
|
||||
// `close` owns reconnection and keeps one timer.
|
||||
})
|
||||
}
|
||||
|
||||
private async executeRuntime(
|
||||
socket: WebSocket,
|
||||
generation: InspectorSourceGeneration,
|
||||
frame: Extract<ReturnType<typeof parseWorkerSourceFrame>, { t: 'client-runtime/request' }>,
|
||||
): Promise<void> {
|
||||
const controller = new AbortController()
|
||||
const operation = { controller, sessionId: frame.sessionId }
|
||||
this.runtimeRequests.set(frame.requestId, operation)
|
||||
const response = await this.runtime.execute(frame, controller.signal, true)
|
||||
if (this.runtimeRequests.get(frame.requestId) !== operation) return
|
||||
if (this.closed || this.socket !== socket || this.generation !== generation || socket.readyState !== WebSocket.OPEN) {
|
||||
this.cancelRuntime(frame.sessionId, frame.requestId)
|
||||
return
|
||||
}
|
||||
socket.send(JSON.stringify(response))
|
||||
}
|
||||
|
||||
private acknowledgeRuntime(sessionId: ClientRuntimeSessionId, requestId: ClientRuntimeRequestId): void {
|
||||
const operation = this.runtimeRequests.get(requestId)
|
||||
if (operation === undefined || operation.sessionId !== sessionId) return
|
||||
this.runtimeRequests.delete(requestId)
|
||||
this.runtime.acknowledge(sessionId, requestId)
|
||||
}
|
||||
|
||||
private cancelRuntime(sessionId: ClientRuntimeSessionId, requestId: ClientRuntimeRequestId): void {
|
||||
const operation = this.runtimeRequests.get(requestId)
|
||||
if (operation === undefined || operation.sessionId !== sessionId) return
|
||||
this.runtimeRequests.delete(requestId)
|
||||
operation.controller.abort()
|
||||
this.runtime.cancel(sessionId, requestId)
|
||||
}
|
||||
|
||||
private cancelRuntimeSession(sessionId: ClientRuntimeSessionId): void {
|
||||
for (const [requestId, operation] of this.runtimeRequests) {
|
||||
if (operation.sessionId !== sessionId) continue
|
||||
operation.controller.abort()
|
||||
this.runtime.cancel(sessionId, requestId)
|
||||
this.runtimeRequests.delete(requestId)
|
||||
}
|
||||
}
|
||||
|
||||
private cancelRuntimeRequests(): void {
|
||||
for (const [requestId, operation] of this.runtimeRequests) {
|
||||
operation.controller.abort()
|
||||
this.runtime.cancel(operation.sessionId, requestId)
|
||||
}
|
||||
this.runtimeRequests.clear()
|
||||
}
|
||||
|
||||
private async executeSourceRequest(
|
||||
socket: WebSocket,
|
||||
generation: InspectorSourceGeneration,
|
||||
frame: ClientSourceRequestFrame,
|
||||
): Promise<void> {
|
||||
let outcome: ClientSourceResponseFrame['outcome']
|
||||
try {
|
||||
if (this.sourceCatalog === undefined) {
|
||||
throw new ClientSourceCatalogError('invalid-request', 'Client source catalog is unavailable')
|
||||
}
|
||||
outcome = { ok: true, result: await this.sourceCatalog.execute(frame.command, this.bootstrap.maxClientSourceBytes) }
|
||||
} catch (error) {
|
||||
outcome = {
|
||||
ok: false,
|
||||
error: {
|
||||
code: error instanceof ClientSourceCatalogError ? error.code : 'internal-error',
|
||||
message: renderError(error).slice(0, 2_048),
|
||||
},
|
||||
}
|
||||
}
|
||||
let response: ClientSourceResponseFrame = {
|
||||
v: INSPECTOR_PROTOCOL_VERSION,
|
||||
t: 'client-sources/response',
|
||||
sourceId: this.realmSource.sourceId,
|
||||
generation,
|
||||
sessionId: frame.sessionId,
|
||||
requestId: frame.requestId,
|
||||
outcome,
|
||||
}
|
||||
if (!isJsonValue(response) || jsonByteLength(response) > this.bootstrap.maxFrameBytes) {
|
||||
response = {
|
||||
...response,
|
||||
outcome: {
|
||||
ok: false,
|
||||
error: { code: 'result-too-large', message: 'Client source result exceeds the source-frame byte limit' },
|
||||
},
|
||||
}
|
||||
}
|
||||
if (this.closed || this.socket !== socket || this.generation !== generation || socket.readyState !== WebSocket.OPEN) return
|
||||
socket.send(JSON.stringify(response))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function renderError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/** Client Console observation shared by every active DevTools Runtime session. */
|
||||
|
||||
import type { ClientRemoteObjectHandle, ClientRuntimeSessionId } from '../../shared/bridge/ids.ts'
|
||||
import type { ClientConsoleCapability } from '../../shared/bridge/messages/runtime/index.ts'
|
||||
import type { RuntimeConsoleBackendEvent, RuntimeConsoleType } from '../../shared/cdp/index.ts'
|
||||
import type { ClientRuntimeExecutor } from './runtime.ts'
|
||||
import { captureClientConsoleStack, clientErrorStack, type ClientScriptKeyResolver } from './stack.ts'
|
||||
|
||||
/**
|
||||
* Describe browser-side Console observation.
|
||||
* @returns The Console capability advertised by a browser Client source.
|
||||
*/
|
||||
export function consoleBridgeCapability(): ClientConsoleCapability {
|
||||
return { type: 'client-console' }
|
||||
}
|
||||
|
||||
/** Receives one Console event whose object handles belong to the given session. */
|
||||
export type ClientConsoleSink = (
|
||||
sessionId: ClientRuntimeSessionId,
|
||||
event: RuntimeConsoleBackendEvent<ClientRemoteObjectHandle>,
|
||||
) => void
|
||||
|
||||
const METHODS = [
|
||||
['log', 'log'],
|
||||
['debug', 'debug'],
|
||||
['info', 'info'],
|
||||
['error', 'error'],
|
||||
['warn', 'warning'],
|
||||
['dir', 'dir'],
|
||||
['dirxml', 'dirxml'],
|
||||
['table', 'table'],
|
||||
['trace', 'trace'],
|
||||
['clear', 'clear'],
|
||||
['group', 'startGroup'],
|
||||
['groupCollapsed', 'startGroupCollapsed'],
|
||||
['groupEnd', 'endGroup'],
|
||||
['assert', 'assert'],
|
||||
['profile', 'profile'],
|
||||
['profileEnd', 'profileEnd'],
|
||||
['count', 'count'],
|
||||
['timeEnd', 'timeEnd'],
|
||||
] as const satisfies readonly (readonly [string, RuntimeConsoleType])[]
|
||||
|
||||
type ConsoleMethodName = typeof METHODS[number][0]
|
||||
|
||||
interface InstalledMethod {
|
||||
readonly name: ConsoleMethodName
|
||||
readonly original: (...args: unknown[]) => unknown
|
||||
readonly replacement: (...args: unknown[]) => unknown
|
||||
}
|
||||
|
||||
/** Installs one transparent console/error observer and fans out session-local values. */
|
||||
export class ClientConsoleObserver {
|
||||
private readonly sessions = new Set<ClientRuntimeSessionId>()
|
||||
private readonly installed: InstalledMethod[] = []
|
||||
private active = false
|
||||
private closed = false
|
||||
|
||||
constructor(
|
||||
private readonly runtime: ClientRuntimeExecutor,
|
||||
private readonly sink: ClientConsoleSink,
|
||||
private readonly resolveScript: ClientScriptKeyResolver = () => undefined,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Start producing events for one DevTools Runtime session.
|
||||
* @param sessionId - Session whose object table retains event arguments.
|
||||
*/
|
||||
enable(sessionId: ClientRuntimeSessionId): void {
|
||||
if (this.closed) return
|
||||
this.sessions.add(sessionId)
|
||||
if (!this.active) this.install()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop producing events and release Console objects for one session.
|
||||
* @param sessionId - Session being disabled or closed.
|
||||
*/
|
||||
disable(sessionId: ClientRuntimeSessionId): void {
|
||||
this.sessions.delete(sessionId)
|
||||
this.runtime.releaseObjectGroup(sessionId, 'console')
|
||||
if (this.sessions.size === 0) this.uninstall()
|
||||
}
|
||||
|
||||
/** Restore original browser hooks and clear every active session. */
|
||||
close(): void {
|
||||
if (this.closed) return
|
||||
this.closed = true
|
||||
this.reset()
|
||||
}
|
||||
|
||||
/** Stop observing the current source generation while allowing a later reconnect. */
|
||||
reset(): void {
|
||||
this.sessions.clear()
|
||||
this.uninstall()
|
||||
}
|
||||
|
||||
private install(): void {
|
||||
this.active = true
|
||||
for (const [name, type] of METHODS) {
|
||||
const candidate: unknown = Reflect.get(console, name)
|
||||
if (typeof candidate !== 'function') continue
|
||||
const original = candidate as (...args: unknown[]) => unknown
|
||||
const capture = (values: readonly unknown[]): void => { this.captureConsole(type, values) }
|
||||
const replacement = function (this: unknown, ...args: unknown[]): unknown {
|
||||
const result = Reflect.apply(original, this, args)
|
||||
const values = name === 'assert' ? args.slice(1) : args
|
||||
if (name !== 'assert' || !args[0]) capture(values)
|
||||
return result
|
||||
}
|
||||
if (Reflect.set(console, name, replacement)) this.installed.push({ name, original, replacement })
|
||||
}
|
||||
addGlobalListener('error', this.onError)
|
||||
addGlobalListener('unhandledrejection', this.onUnhandledRejection)
|
||||
}
|
||||
|
||||
private uninstall(): void {
|
||||
if (!this.active) return
|
||||
this.active = false
|
||||
removeGlobalListener('error', this.onError)
|
||||
removeGlobalListener('unhandledrejection', this.onUnhandledRejection)
|
||||
for (const method of this.installed.splice(0).reverse()) {
|
||||
if (Reflect.get(console, method.name) === method.replacement) Reflect.set(console, method.name, method.original)
|
||||
}
|
||||
}
|
||||
|
||||
private readonly onError = (event: Event): void => {
|
||||
const error = Reflect.get(event, 'error') as unknown
|
||||
const message = Reflect.get(event, 'message') as unknown
|
||||
this.captureException(error ?? new Error(typeof message === 'string' ? message : 'Client error'))
|
||||
}
|
||||
|
||||
private readonly onUnhandledRejection = (event: Event): void => {
|
||||
this.captureException(Reflect.get(event, 'reason') as unknown)
|
||||
}
|
||||
|
||||
private captureConsole(type: RuntimeConsoleType, values: readonly unknown[]): void {
|
||||
const timestamp = Date.now()
|
||||
const stackTrace = captureClientConsoleStack(this.resolveScript)
|
||||
queueMicrotask(() => {
|
||||
for (const sessionId of [...this.sessions]) {
|
||||
try {
|
||||
const event = this.runtime.consoleEvent(sessionId, type, values, timestamp, stackTrace)
|
||||
if (event !== undefined) this.sink(sessionId, event)
|
||||
} catch {
|
||||
// Console observation must not affect the page's original console call.
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private captureException(error: unknown): void {
|
||||
const timestamp = Date.now()
|
||||
const stackTrace = clientErrorStack(error, this.resolveScript)
|
||||
queueMicrotask(() => {
|
||||
for (const sessionId of [...this.sessions]) {
|
||||
try {
|
||||
const event = this.runtime.exceptionEvent(sessionId, error, timestamp, stackTrace)
|
||||
if (event !== undefined) this.sink(sessionId, event)
|
||||
} catch {
|
||||
// Exception observation must not affect browser error dispatch.
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function addGlobalListener(type: string, listener: EventListener): void {
|
||||
const add = Reflect.get(globalThis, 'addEventListener') as unknown
|
||||
if (typeof add === 'function') Reflect.apply(add, globalThis, [type, listener])
|
||||
}
|
||||
|
||||
function removeGlobalListener(type: string, listener: EventListener): void {
|
||||
const remove = Reflect.get(globalThis, 'removeEventListener') as unknown
|
||||
if (typeof remove === 'function') Reflect.apply(remove, globalThis, [type, listener])
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** Client active debugging is not exposed by the source bridge. */
|
||||
|
||||
import type { InspectorSourceCapability } from '../../shared/bridge/messages/observation.ts'
|
||||
|
||||
/**
|
||||
* Describe unavailable browser-side active debugging.
|
||||
* @returns No source capability until a pause-safe Client debugger agent exists.
|
||||
*/
|
||||
export function debuggerBridgeCapability(): InspectorSourceCapability | undefined {
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Client Runtime failures that belong to the transport rather than evaluated JavaScript. */
|
||||
|
||||
import type { ClientRuntimeError } from '../../shared/bridge/messages/runtime/index.ts'
|
||||
|
||||
/** Failure returned through the typed Client Runtime error outcome. */
|
||||
export class ClientRuntimeExecutionError extends Error {
|
||||
constructor(readonly code: ClientRuntimeError['code'], message: string) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** Client heap profiling is not exposed by the source bridge. */
|
||||
|
||||
import type { InspectorSourceCapability } from '../../shared/bridge/messages/observation.ts'
|
||||
|
||||
/**
|
||||
* Describe unavailable browser-side heap profiling.
|
||||
* @returns No source capability for Client heap profiling.
|
||||
*/
|
||||
export function heapProfilerBridgeCapability(): InspectorSourceCapability | undefined {
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/** Source-side CDP capability declarations for the browser Client realm. */
|
||||
|
||||
import type { InspectorSourceCapability } from '../../shared/bridge/messages/observation.ts'
|
||||
import { consoleBridgeCapability } from './console.ts'
|
||||
import { debuggerBridgeCapability } from './debugger.ts'
|
||||
import { heapProfilerBridgeCapability } from './heap-profiler.ts'
|
||||
import { profilerBridgeCapability } from './profiler.ts'
|
||||
import { runtimeBridgeCapability } from './runtime.ts'
|
||||
import { sourcesBridgeCapability } from './sources.ts'
|
||||
|
||||
/**
|
||||
* Describe Client operations that require Worker-to-page bridge messages.
|
||||
* @param origin - Origin assigned to the synthetic execution context.
|
||||
* @param hasSources - Whether the Client bundle source was discovered.
|
||||
* @returns Capabilities included in the Client source handshake.
|
||||
*/
|
||||
export function bridgeCapabilities(origin: string, hasSources: boolean): readonly InspectorSourceCapability[] {
|
||||
return [
|
||||
runtimeBridgeCapability(origin),
|
||||
consoleBridgeCapability(),
|
||||
sourcesBridgeCapability(hasSources),
|
||||
debuggerBridgeCapability(),
|
||||
profilerBridgeCapability(),
|
||||
heapProfilerBridgeCapability(),
|
||||
].filter((capability): capability is InspectorSourceCapability => capability !== undefined)
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
/** Client-local object handles and CDP-compatible RemoteObject serialization. */
|
||||
|
||||
import {
|
||||
inspectorId,
|
||||
type ClientRemoteObjectHandle,
|
||||
} from '../../shared/bridge/ids.ts'
|
||||
import { isJsonValue, type InspectorJsonValue } from '../../shared/json.ts'
|
||||
import type { ClientRuntimeRemoteObject } from '../../shared/bridge/messages/runtime/index.ts'
|
||||
import type {
|
||||
RuntimeObjectPreview,
|
||||
RuntimePropertyPreview,
|
||||
RuntimeRemoteObjectSubtype,
|
||||
RuntimeRemoteObjectType,
|
||||
} from '../../shared/cdp/index.ts'
|
||||
import { ClientRuntimeExecutionError } from './errors.ts'
|
||||
import { identifyRealmObject } from '../../shared/cordis/object-registry.ts'
|
||||
|
||||
const MAX_CLASS_PROTOTYPE_DEPTH = 32
|
||||
|
||||
interface StoredObject {
|
||||
readonly value: unknown
|
||||
readonly group: string | undefined
|
||||
}
|
||||
|
||||
/** Opaque set of handles allocated by one Client Runtime operation. */
|
||||
export type ClientObjectAllocation = symbol
|
||||
|
||||
/** Serialization choices inherited by child RemoteObjects. */
|
||||
export interface ClientRuntimeObjectOptions {
|
||||
readonly group?: string
|
||||
readonly generatePreview?: boolean
|
||||
readonly returnByValue?: boolean
|
||||
}
|
||||
|
||||
/** Per-DevTools-session owner of all live Client object references. */
|
||||
export class ClientObjectStore {
|
||||
private readonly objects = new Map<ClientRemoteObjectHandle, StoredObject>()
|
||||
private readonly groups = new Map<string, Set<ClientRemoteObjectHandle>>()
|
||||
private readonly allocations = new Map<ClientObjectAllocation, Set<ClientRemoteObjectHandle>>()
|
||||
private nextOrdinal = 1
|
||||
|
||||
constructor(private readonly maxObjects: number) {}
|
||||
|
||||
/**
|
||||
* Start tracking handles allocated by one independently settling operation.
|
||||
* @returns An opaque allocation identity.
|
||||
*/
|
||||
beginAllocation(): ClientObjectAllocation {
|
||||
const allocation = Symbol('Client Runtime object allocation')
|
||||
this.allocations.set(allocation, new Set())
|
||||
return allocation
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep an operation's handles and release its allocation bookkeeping.
|
||||
* @param allocation - Allocation returned by {@link beginAllocation}.
|
||||
*/
|
||||
commitAllocation(allocation: ClientObjectAllocation): void {
|
||||
this.allocations.delete(allocation)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one handle or fail without exposing another session's objects.
|
||||
* @param handle - Client-local object handle.
|
||||
* @returns The retained JavaScript value.
|
||||
*/
|
||||
get(handle: ClientRemoteObjectHandle): unknown {
|
||||
const object = this.objects.get(handle)
|
||||
if (object === undefined) throw new ClientRuntimeExecutionError('object-not-found', 'Client RemoteObject was released')
|
||||
return object.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the object group inherited by values reached through one handle.
|
||||
* @param handle - Client-local object handle.
|
||||
* @returns Its object group, or `undefined` when it is ungrouped.
|
||||
*/
|
||||
group(handle: ClientRemoteObjectHandle): string | undefined {
|
||||
const object = this.objects.get(handle)
|
||||
if (object === undefined) throw new ClientRuntimeExecutionError('object-not-found', 'Client RemoteObject was released')
|
||||
return object.group
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a live value to the JSON-safe RemoteObject protocol.
|
||||
* @param value - Value owned by this Client realm.
|
||||
* @param options - Object group and serialization options.
|
||||
* @param allocation - Optional operation that owns any newly retained handle.
|
||||
* @returns A primitive value or opaque Client handle with display metadata.
|
||||
*/
|
||||
serialize(
|
||||
value: unknown,
|
||||
options: ClientRuntimeObjectOptions = {},
|
||||
allocation?: ClientObjectAllocation,
|
||||
): ClientRuntimeRemoteObject {
|
||||
const primitive = serializePrimitive(value)
|
||||
if (primitive !== undefined) return primitive
|
||||
if (options.returnByValue === true) {
|
||||
return {
|
||||
descriptor: {
|
||||
type: typeof value === 'function' ? 'function' : 'object',
|
||||
value: serializeByValue(value),
|
||||
description: describe(value),
|
||||
},
|
||||
}
|
||||
}
|
||||
const type: RuntimeRemoteObjectType = typeof value === 'function' ? 'function' : typeof value === 'symbol' ? 'symbol' : 'object'
|
||||
const subtype = type === 'object' ? subtypeOf(value) : undefined
|
||||
const objectReference = identifyRealmObject(value)
|
||||
return {
|
||||
descriptor: {
|
||||
type,
|
||||
...(subtype === undefined ? {} : { subtype }),
|
||||
className: className(value),
|
||||
description: describe(value),
|
||||
...(options.generatePreview === true && type === 'object' ? { preview: preview(value, type, subtype) } : {}),
|
||||
},
|
||||
object: { handle: this.register(value, options.group, allocation) },
|
||||
...(objectReference === undefined ? {} : { semanticReference: objectReference }),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release exactly one handle. Releasing an unknown handle is idempotent.
|
||||
* @param handle - Client-local object handle.
|
||||
*/
|
||||
release(handle: ClientRemoteObjectHandle): void {
|
||||
const object = this.objects.get(handle)
|
||||
if (object === undefined) return
|
||||
this.objects.delete(handle)
|
||||
if (object.group === undefined) return
|
||||
const members = this.groups.get(object.group)
|
||||
members?.delete(handle)
|
||||
if (members?.size === 0) this.groups.delete(object.group)
|
||||
}
|
||||
|
||||
/**
|
||||
* Release every handle in one DevTools object group.
|
||||
* @param group - DevTools object-group name.
|
||||
*/
|
||||
releaseGroup(group: string): void {
|
||||
const members = this.groups.get(group)
|
||||
if (members === undefined) return
|
||||
for (const handle of members) this.objects.delete(handle)
|
||||
this.groups.delete(group)
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard exactly the handles allocated by one failed operation.
|
||||
* @param allocation - Allocation returned by {@link beginAllocation}.
|
||||
*/
|
||||
rollback(allocation: ClientObjectAllocation): void {
|
||||
const handles = this.allocations.get(allocation)
|
||||
if (handles === undefined) return
|
||||
this.allocations.delete(allocation)
|
||||
for (const handle of handles) this.release(handle)
|
||||
}
|
||||
|
||||
/** Release the whole DevTools session. */
|
||||
clear(): void {
|
||||
this.objects.clear()
|
||||
this.groups.clear()
|
||||
this.allocations.clear()
|
||||
}
|
||||
|
||||
private register(
|
||||
value: unknown,
|
||||
group: string | undefined,
|
||||
allocation: ClientObjectAllocation | undefined,
|
||||
): ClientRemoteObjectHandle {
|
||||
if (this.objects.size >= this.maxObjects) {
|
||||
throw new ClientRuntimeExecutionError('result-too-large', `Client Runtime retained-object limit ${String(this.maxObjects)} reached`)
|
||||
}
|
||||
const ordinal = this.nextOrdinal++
|
||||
const handle = inspectorId<'ClientRemoteObjectHandle'>(`object-${String(ordinal)}`, 'handle')
|
||||
this.objects.set(handle, { value, group })
|
||||
if (allocation !== undefined) this.allocations.get(allocation)?.add(handle)
|
||||
if (group !== undefined) {
|
||||
let members = this.groups.get(group)
|
||||
if (members === undefined) {
|
||||
members = new Set()
|
||||
this.groups.set(group, members)
|
||||
}
|
||||
members.add(handle)
|
||||
}
|
||||
return handle
|
||||
}
|
||||
}
|
||||
|
||||
function serializePrimitive(value: unknown): ClientRuntimeRemoteObject | undefined {
|
||||
if (value === undefined) return { descriptor: { type: 'undefined' } }
|
||||
if (value === null) return { descriptor: { type: 'object', subtype: 'null', value: null } }
|
||||
if (typeof value === 'string') return { descriptor: { type: 'string', value } }
|
||||
if (typeof value === 'boolean') return { descriptor: { type: 'boolean', value } }
|
||||
if (typeof value === 'bigint') {
|
||||
const text = `${String(value)}n`
|
||||
return { descriptor: { type: 'bigint', unserializableValue: text, description: text } }
|
||||
}
|
||||
if (typeof value !== 'number') return undefined
|
||||
if (Number.isFinite(value) && !Object.is(value, -0)) {
|
||||
return { descriptor: { type: 'number', value, description: String(value) } }
|
||||
}
|
||||
const text = Object.is(value, -0) ? '-0' : String(value)
|
||||
return { descriptor: { type: 'number', unserializableValue: text, description: text } }
|
||||
}
|
||||
|
||||
function serializeByValue(value: unknown): InspectorJsonValue {
|
||||
let serialized: unknown
|
||||
try {
|
||||
serialized = JSON.stringify(value)
|
||||
} catch (error) {
|
||||
throw new ClientRuntimeExecutionError('unsupported', `Value cannot be returned by value: ${renderError(error)}`)
|
||||
}
|
||||
if (typeof serialized !== 'string') throw new ClientRuntimeExecutionError('unsupported', 'Value cannot be returned by value')
|
||||
const result = JSON.parse(serialized) as unknown
|
||||
if (!isJsonValue(result)) throw new ClientRuntimeExecutionError('unsupported', 'Value is outside the JSON value set')
|
||||
return result
|
||||
}
|
||||
|
||||
function preview(
|
||||
value: unknown,
|
||||
type: RuntimeRemoteObjectType,
|
||||
subtype: RuntimeRemoteObjectSubtype | undefined,
|
||||
): RuntimeObjectPreview {
|
||||
const properties: RuntimePropertyPreview[] = []
|
||||
let overflow = false
|
||||
if ((typeof value === 'object' && value !== null) || typeof value === 'function') {
|
||||
let keys: readonly PropertyKey[] = []
|
||||
try {
|
||||
keys = Reflect.ownKeys(value)
|
||||
} catch {
|
||||
overflow = true
|
||||
}
|
||||
for (const key of keys) {
|
||||
if (properties.length === 5) {
|
||||
overflow = true
|
||||
break
|
||||
}
|
||||
let descriptor: PropertyDescriptor | undefined
|
||||
try {
|
||||
descriptor = Reflect.getOwnPropertyDescriptor(value, key)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (descriptor === undefined) continue
|
||||
if (!('value' in descriptor)) {
|
||||
properties.push({ name: String(key), type: 'accessor' })
|
||||
continue
|
||||
}
|
||||
const propertyType = remoteType(descriptor.value)
|
||||
const propertySubtype = propertyType === 'object' ? subtypeOf(descriptor.value) : undefined
|
||||
properties.push({
|
||||
name: String(key),
|
||||
type: propertyType,
|
||||
value: previewText(descriptor.value),
|
||||
...(propertySubtype === undefined ? {} : { subtype: propertySubtype }),
|
||||
})
|
||||
}
|
||||
}
|
||||
return {
|
||||
type,
|
||||
...(subtype === undefined ? {} : { subtype }),
|
||||
description: describe(value),
|
||||
overflow,
|
||||
properties,
|
||||
}
|
||||
}
|
||||
|
||||
function remoteType(value: unknown): RuntimeRemoteObjectType {
|
||||
if (value === null) return 'object'
|
||||
return typeof value
|
||||
}
|
||||
|
||||
function subtypeOf(value: unknown): RuntimeRemoteObjectSubtype | undefined {
|
||||
if (value === null) return 'null'
|
||||
if (Array.isArray(value)) return 'array'
|
||||
if (ArrayBuffer.isView(value)) return value instanceof DataView ? 'dataview' : 'typedarray'
|
||||
if (typeof value !== 'object') return undefined
|
||||
for (const [prototype, subtype] of SUBTYPES_BY_PROTOTYPE) {
|
||||
if (inheritsFrom(value, prototype)) return subtype
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function className(value: unknown): string {
|
||||
if (typeof value === 'function') return functionName(value)
|
||||
if (typeof value === 'symbol') return 'Symbol'
|
||||
if (typeof value !== 'object' || value === null) return 'Object'
|
||||
const visited = new Set<object>()
|
||||
let prototype = prototypeOf(value)
|
||||
while (prototype !== null && visited.size < MAX_CLASS_PROTOTYPE_DEPTH && !visited.has(prototype)) {
|
||||
visited.add(prototype)
|
||||
const constructor = Reflect.getOwnPropertyDescriptor(prototype, 'constructor')
|
||||
const candidate: unknown = constructor !== undefined && 'value' in constructor ? constructor.value : undefined
|
||||
if (typeof candidate === 'function') {
|
||||
return functionName(candidate)
|
||||
}
|
||||
prototype = prototypeOf(prototype)
|
||||
}
|
||||
return 'Object'
|
||||
}
|
||||
|
||||
function describe(value: unknown): string {
|
||||
if (typeof value === 'function') {
|
||||
try {
|
||||
return Function.prototype.toString.call(value)
|
||||
} catch {
|
||||
return functionName(value)
|
||||
}
|
||||
}
|
||||
const subtype = subtypeOf(value)
|
||||
if (subtype === 'array') {
|
||||
const descriptor = Reflect.getOwnPropertyDescriptor(value as object, 'length')
|
||||
const length: unknown = descriptor !== undefined && 'value' in descriptor ? descriptor.value : undefined
|
||||
return `Array(${typeof length === 'number' ? String(length) : '?'})`
|
||||
}
|
||||
if (subtype === 'error') {
|
||||
const stack = ownString(value as object, 'stack')
|
||||
if (stack !== undefined) return stack
|
||||
const name = ownString(value as object, 'name') ?? className(value)
|
||||
const message = ownString(value as object, 'message')
|
||||
return message === undefined || message.length === 0 ? name : `${name}: ${message}`
|
||||
}
|
||||
if (subtype === 'date') {
|
||||
try {
|
||||
return Date.prototype.toString.call(value)
|
||||
} catch {
|
||||
return 'Date'
|
||||
}
|
||||
}
|
||||
if (subtype === 'regexp') {
|
||||
try {
|
||||
return RegExp.prototype.toString.call(value)
|
||||
} catch {
|
||||
return 'RegExp'
|
||||
}
|
||||
}
|
||||
return className(value)
|
||||
}
|
||||
|
||||
function previewText(value: unknown): string {
|
||||
if (typeof value === 'string') return value.slice(0, 100)
|
||||
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint' || typeof value === 'symbol') {
|
||||
return String(value)
|
||||
}
|
||||
if (value === null) return 'null'
|
||||
if (value === undefined) return 'undefined'
|
||||
return describe(value).slice(0, 100)
|
||||
}
|
||||
|
||||
function functionName(value: object): string {
|
||||
try {
|
||||
const descriptor = Reflect.getOwnPropertyDescriptor(value, 'name')
|
||||
const name: unknown = descriptor !== undefined && 'value' in descriptor ? descriptor.value : undefined
|
||||
return typeof name === 'string' && name.length > 0 ? name : 'Function'
|
||||
} catch {
|
||||
return 'Function'
|
||||
}
|
||||
}
|
||||
|
||||
function prototypeOf(value: object): object | null {
|
||||
try {
|
||||
return Reflect.getPrototypeOf(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function inheritsFrom(value: object, expected: object): boolean {
|
||||
const visited = new Set<object>()
|
||||
let current = prototypeOf(value)
|
||||
while (current !== null && visited.size < MAX_CLASS_PROTOTYPE_DEPTH && !visited.has(current)) {
|
||||
if (current === expected) return true
|
||||
visited.add(current)
|
||||
current = prototypeOf(current)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function ownString(value: object, key: string): string | undefined {
|
||||
try {
|
||||
const descriptor = Reflect.getOwnPropertyDescriptor(value, key)
|
||||
return descriptor !== undefined && 'value' in descriptor && typeof descriptor.value === 'string'
|
||||
? descriptor.value
|
||||
: undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function renderError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
const SUBTYPES_BY_PROTOTYPE: readonly (readonly [object, RuntimeRemoteObjectSubtype])[] = [
|
||||
[RegExp.prototype, 'regexp'],
|
||||
[Date.prototype, 'date'],
|
||||
[Map.prototype, 'map'],
|
||||
[Set.prototype, 'set'],
|
||||
[WeakMap.prototype, 'weakmap'],
|
||||
[WeakSet.prototype, 'weakset'],
|
||||
[Error.prototype, 'error'],
|
||||
[Promise.prototype, 'promise'],
|
||||
[ArrayBuffer.prototype, 'arraybuffer'],
|
||||
[DataView.prototype, 'dataview'],
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
/** Client CPU profiling is not exposed by the source bridge. */
|
||||
|
||||
import type { InspectorSourceCapability } from '../../shared/bridge/messages/observation.ts'
|
||||
|
||||
/**
|
||||
* Describe unavailable browser-side CPU profiling.
|
||||
* @returns No source capability for Client CPU profiling.
|
||||
*/
|
||||
export function profilerBridgeCapability(): InspectorSourceCapability | undefined {
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/** Lazy Client property enumeration for `Runtime.getProperties`. */
|
||||
|
||||
import type {
|
||||
ClientRuntimeGetPropertiesCommand,
|
||||
ClientRuntimeInternalPropertyDescriptor,
|
||||
ClientRuntimePropertyDescriptor,
|
||||
} from '../../shared/bridge/messages/runtime/index.ts'
|
||||
import { ClientRuntimeExecutionError } from './errors.ts'
|
||||
import { ClientObjectStore, type ClientObjectAllocation } from './objects.ts'
|
||||
|
||||
/**
|
||||
* Read property descriptors without invoking getters.
|
||||
* @param objects - Object table that owns the requested handle.
|
||||
* @param command - Validated property request.
|
||||
* @param maxProperties - Maximum descriptors returned by this operation.
|
||||
* @param allocation - Current operation's object-allocation identity.
|
||||
* @returns Own or inherited descriptors and the immediate prototype.
|
||||
*/
|
||||
export function getClientProperties(
|
||||
objects: ClientObjectStore,
|
||||
command: ClientRuntimeGetPropertiesCommand,
|
||||
maxProperties: number,
|
||||
allocation: ClientObjectAllocation,
|
||||
): {
|
||||
readonly properties: readonly ClientRuntimePropertyDescriptor[]
|
||||
readonly internalProperties?: readonly ClientRuntimeInternalPropertyDescriptor[]
|
||||
} {
|
||||
const raw = objects.get(command.handle)
|
||||
if (!isObjectLike(raw)) return { properties: [] }
|
||||
const value: object = typeof raw === 'symbol' ? Symbol.prototype : raw
|
||||
const group = objects.group(command.handle)
|
||||
const properties: ClientRuntimePropertyDescriptor[] = []
|
||||
const seen = new Set<PropertyKey>()
|
||||
const visited = new Set<object>()
|
||||
let owner: object | null = value
|
||||
let own = true
|
||||
|
||||
while (owner !== null) {
|
||||
if (visited.has(owner) || visited.size >= maxProperties) {
|
||||
throw new ClientRuntimeExecutionError('result-too-large', 'Client prototype traversal exceeded its configured limit')
|
||||
}
|
||||
visited.add(owner)
|
||||
const keys = readKeys(owner)
|
||||
for (const key of keys) {
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
if (command.nonIndexedPropertiesOnly === true && typeof key === 'string' && isArrayIndex(key)) continue
|
||||
const descriptor = readDescriptor(owner, key)
|
||||
if (descriptor === undefined) continue
|
||||
if (command.accessorPropertiesOnly === true && 'value' in descriptor) continue
|
||||
if (properties.length >= maxProperties) {
|
||||
throw new ClientRuntimeExecutionError(
|
||||
'result-too-large',
|
||||
`Client property result exceeds the configured ${String(maxProperties)}-property limit`,
|
||||
)
|
||||
}
|
||||
properties.push(toRemoteDescriptor(
|
||||
objects,
|
||||
key,
|
||||
descriptor,
|
||||
group,
|
||||
own,
|
||||
command.generatePreview === true,
|
||||
allocation,
|
||||
))
|
||||
}
|
||||
if (command.ownProperties === true) break
|
||||
owner = readPrototype(owner)
|
||||
own = false
|
||||
}
|
||||
|
||||
if (command.accessorPropertiesOnly === true) return { properties }
|
||||
const prototype = readPrototype(value)
|
||||
const internalProperties: ClientRuntimeInternalPropertyDescriptor[] = prototype === null
|
||||
? []
|
||||
: [{
|
||||
name: '[[Prototype]]',
|
||||
value: objects.serialize(prototype, remoteOptions(group, command.generatePreview), allocation),
|
||||
}]
|
||||
return { properties, internalProperties }
|
||||
}
|
||||
|
||||
function toRemoteDescriptor(
|
||||
objects: ClientObjectStore,
|
||||
key: PropertyKey,
|
||||
descriptor: PropertyDescriptor,
|
||||
group: string | undefined,
|
||||
own: boolean,
|
||||
generatePreview: boolean,
|
||||
allocation: ClientObjectAllocation,
|
||||
): ClientRuntimePropertyDescriptor {
|
||||
const common = {
|
||||
name: typeof key === 'symbol' ? key.description ?? String(key) : String(key),
|
||||
configurable: descriptor.configurable ?? false,
|
||||
enumerable: descriptor.enumerable ?? false,
|
||||
isOwn: own,
|
||||
...(typeof key === 'symbol' ? { symbol: objects.serialize(key, remoteOptions(group), allocation) } : {}),
|
||||
}
|
||||
if ('value' in descriptor) {
|
||||
return {
|
||||
...common,
|
||||
value: objects.serialize(descriptor.value, remoteOptions(group, generatePreview), allocation),
|
||||
writable: descriptor.writable ?? false,
|
||||
}
|
||||
}
|
||||
const getter = Reflect.get(descriptor, 'get') as (() => unknown) | undefined
|
||||
const setter = Reflect.get(descriptor, 'set') as ((value: unknown) => void) | undefined
|
||||
return {
|
||||
...common,
|
||||
...(getter === undefined ? {} : { get: objects.serialize(getter, remoteOptions(group), allocation) }),
|
||||
...(setter === undefined ? {} : { set: objects.serialize(setter, remoteOptions(group), allocation) }),
|
||||
}
|
||||
}
|
||||
|
||||
function readKeys(value: object): readonly PropertyKey[] {
|
||||
try {
|
||||
return Reflect.ownKeys(value)
|
||||
} catch (error) {
|
||||
throw new ClientRuntimeExecutionError('internal-error', `Cannot enumerate Client object: ${renderError(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function readDescriptor(value: object, key: PropertyKey): PropertyDescriptor | undefined {
|
||||
try {
|
||||
return Reflect.getOwnPropertyDescriptor(value, key)
|
||||
} catch (error) {
|
||||
throw new ClientRuntimeExecutionError('internal-error', `Cannot read Client property ${String(key)}: ${renderError(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function readPrototype(value: object): object | null {
|
||||
try {
|
||||
return Object.getPrototypeOf(value) as object | null
|
||||
} catch (error) {
|
||||
throw new ClientRuntimeExecutionError('internal-error', `Cannot read Client object prototype: ${renderError(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function isObjectLike(value: unknown): value is object | symbol {
|
||||
return (typeof value === 'object' && value !== null) || typeof value === 'function' || typeof value === 'symbol'
|
||||
}
|
||||
|
||||
function isArrayIndex(value: string): boolean {
|
||||
const number = Number(value)
|
||||
return Number.isInteger(number) && number >= 0 && number < 4_294_967_295 && String(number) === value
|
||||
}
|
||||
|
||||
function renderError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function remoteOptions(group: string | undefined, generatePreview?: boolean): {
|
||||
readonly group?: string
|
||||
readonly generatePreview?: boolean
|
||||
} {
|
||||
return {
|
||||
...(group === undefined ? {} : { group }),
|
||||
...(generatePreview === undefined ? {} : { generatePreview }),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
/** Client-realm executor for the typed Runtime command protocol. */
|
||||
|
||||
import type {
|
||||
ClientCallArgument,
|
||||
ClientRuntimeCapability,
|
||||
ClientRuntimeCommand,
|
||||
ClientRuntimeCompletion,
|
||||
ClientRuntimeError,
|
||||
ClientRuntimeExceptionDetails,
|
||||
ClientRuntimeRequestFrame,
|
||||
ClientRuntimeResponseFrame,
|
||||
ClientRuntimeResult,
|
||||
ClientRuntimeRemoteObject,
|
||||
} from '../../shared/bridge/messages/runtime/index.ts'
|
||||
import type {
|
||||
ClientRemoteObjectHandle,
|
||||
ClientRuntimeRequestId,
|
||||
ClientRuntimeSessionId,
|
||||
} from '../../shared/bridge/ids.ts'
|
||||
import { isJsonValue, jsonByteLength } from '../../shared/json.ts'
|
||||
import { INSPECTOR_PROTOCOL_VERSION } from '../../shared/bridge/version.ts'
|
||||
import { ClientRuntimeExecutionError } from './errors.ts'
|
||||
import type { RuntimeConsoleBackendEvent, RuntimeConsoleType, RuntimeStackTrace } from '../../shared/cdp/index.ts'
|
||||
import { ClientObjectStore, type ClientObjectAllocation } from './objects.ts'
|
||||
import { getClientProperties } from './properties.ts'
|
||||
import { clientErrorStack, type ClientScriptKeyResolver } from './stack.ts'
|
||||
|
||||
const MAX_RUNTIME_ERROR_MESSAGE_LENGTH = 2_048
|
||||
|
||||
/**
|
||||
* Describe browser-side Runtime execution.
|
||||
* @param origin - Origin assigned to the synthetic execution context.
|
||||
* @returns The Runtime capability advertised by a browser Client source.
|
||||
*/
|
||||
export function runtimeBridgeCapability(origin: string): ClientRuntimeCapability {
|
||||
return { type: 'client-runtime', origin }
|
||||
}
|
||||
|
||||
/** Client-side limits injected by the Host deployment. */
|
||||
export interface ClientRuntimeLimits {
|
||||
readonly maxObjectsPerSession: number
|
||||
readonly maxPropertiesPerResult: number
|
||||
readonly maxResponseBytes: number
|
||||
}
|
||||
|
||||
/** Executes Runtime requests while isolating object handles by DevTools session. */
|
||||
export class ClientRuntimeExecutor {
|
||||
private readonly sessions = new Map<ClientRuntimeSessionId, ClientRuntimeSession>()
|
||||
private readonly responseAllocations = new Map<ClientRuntimeRequestId, {
|
||||
readonly sessionId: ClientRuntimeSessionId
|
||||
readonly session: ClientRuntimeSession
|
||||
readonly allocation: ClientObjectAllocation
|
||||
}>()
|
||||
|
||||
constructor(
|
||||
private readonly limits: ClientRuntimeLimits,
|
||||
private readonly resolveScript: ClientScriptKeyResolver = () => undefined,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Execute one request and preserve its source, generation, session, and request identities.
|
||||
* @param frame - Validated command envelope from the Worker.
|
||||
* @param signal - Optional cancellation for an operation awaiting user code.
|
||||
* @param deferObjectCommit - Keep new object handles provisional until {@link acknowledge}.
|
||||
* @returns A success or transport-error response for the same request.
|
||||
*/
|
||||
async execute(
|
||||
frame: ClientRuntimeRequestFrame,
|
||||
signal?: AbortSignal,
|
||||
deferObjectCommit = false,
|
||||
): Promise<ClientRuntimeResponseFrame> {
|
||||
const session = this.session(frame.sessionId)
|
||||
const allocation = session.beginAllocation()
|
||||
try {
|
||||
const result = await session.execute(frame.command, allocation, signal)
|
||||
if (signal?.aborted === true) {
|
||||
throw new ClientRuntimeExecutionError('timeout', 'Client Runtime request was canceled')
|
||||
}
|
||||
const response = responseFrame(frame, { ok: true, result })
|
||||
if (!isJsonValue(response) || jsonByteLength(response) > this.limits.maxResponseBytes) {
|
||||
session.rollback(allocation)
|
||||
return responseFrame(frame, {
|
||||
ok: false,
|
||||
error: { code: 'result-too-large', message: 'Client Runtime result exceeds the source-frame byte limit' },
|
||||
})
|
||||
}
|
||||
if (deferObjectCommit) {
|
||||
if (this.responseAllocations.has(frame.requestId)) {
|
||||
session.rollback(allocation)
|
||||
return responseFrame(frame, {
|
||||
ok: false,
|
||||
error: { code: 'invalid-request', message: 'Client Runtime request id is already pending' },
|
||||
})
|
||||
}
|
||||
this.responseAllocations.set(frame.requestId, { sessionId: frame.sessionId, session, allocation })
|
||||
} else {
|
||||
session.commitAllocation(allocation)
|
||||
}
|
||||
return response
|
||||
} catch (error) {
|
||||
session.rollback(allocation)
|
||||
return responseFrame(frame, { ok: false, error: runtimeError(error) })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit handles after the Worker accepts one Runtime response.
|
||||
* @param sessionId - Session that owns the response.
|
||||
* @param requestId - Correlation id acknowledged by the Worker.
|
||||
*/
|
||||
acknowledge(sessionId: ClientRuntimeSessionId, requestId: ClientRuntimeRequestId): void {
|
||||
const pending = this.responseAllocations.get(requestId)
|
||||
if (pending === undefined || pending.sessionId !== sessionId) return
|
||||
this.responseAllocations.delete(requestId)
|
||||
pending.session.commitAllocation(pending.allocation)
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll back handles from a canceled or otherwise unaccepted Runtime response.
|
||||
* @param sessionId - Session that owns the response.
|
||||
* @param requestId - Correlation id rejected by the Worker.
|
||||
*/
|
||||
cancel(sessionId: ClientRuntimeSessionId, requestId: ClientRuntimeRequestId): void {
|
||||
const pending = this.responseAllocations.get(requestId)
|
||||
if (pending === undefined || pending.sessionId !== sessionId) return
|
||||
this.responseAllocations.delete(requestId)
|
||||
pending.session.rollback(pending.allocation)
|
||||
}
|
||||
|
||||
/**
|
||||
* Release all values retained for one closed DevTools connection.
|
||||
* @param sessionId - Runtime session owned by that DevTools connection.
|
||||
*/
|
||||
closeSession(sessionId: ClientRuntimeSessionId): void {
|
||||
for (const [requestId, pending] of this.responseAllocations) {
|
||||
if (pending.sessionId === sessionId) this.responseAllocations.delete(requestId)
|
||||
}
|
||||
this.sessions.get(sessionId)?.close()
|
||||
this.sessions.delete(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Release one object group without closing the surrounding Runtime session.
|
||||
* @param sessionId - Session that owns the retained objects.
|
||||
* @param group - Object-group name to release.
|
||||
*/
|
||||
releaseObjectGroup(sessionId: ClientRuntimeSessionId, group: string): void {
|
||||
this.sessions.get(sessionId)?.releaseObjectGroup(group)
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize one Console call for a specific DevTools Runtime session.
|
||||
* @param sessionId - Session receiving the Console event.
|
||||
* @param type - Console API operation.
|
||||
* @param values - Original arguments from the page call.
|
||||
* @param timestamp - Epoch timestamp in milliseconds.
|
||||
* @param stackTrace - Browser call frames captured before deferred delivery.
|
||||
* @returns A wire-safe event whose object handles belong only to this session.
|
||||
*/
|
||||
consoleEvent(
|
||||
sessionId: ClientRuntimeSessionId,
|
||||
type: RuntimeConsoleType,
|
||||
values: readonly unknown[],
|
||||
timestamp: number,
|
||||
stackTrace?: RuntimeStackTrace,
|
||||
): RuntimeConsoleBackendEvent<ClientRemoteObjectHandle> | undefined {
|
||||
const session = this.session(sessionId)
|
||||
const allocation = session.beginAllocation()
|
||||
try {
|
||||
const event: RuntimeConsoleBackendEvent<ClientRemoteObjectHandle> = {
|
||||
type: 'console-api',
|
||||
event: {
|
||||
type,
|
||||
arguments: session.serializeAll(values, 'console', allocation),
|
||||
timestamp,
|
||||
...(stackTrace === undefined ? {} : { stackTrace }),
|
||||
},
|
||||
}
|
||||
if (!isJsonValue(event) || jsonByteLength(event) + 4_096 > this.limits.maxResponseBytes) {
|
||||
session.rollback(allocation)
|
||||
return undefined
|
||||
}
|
||||
session.commitAllocation(allocation)
|
||||
return event
|
||||
} catch (error) {
|
||||
session.rollback(allocation)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize one uncaught Client exception for a DevTools Runtime session.
|
||||
* @param sessionId - Session receiving the exception event.
|
||||
* @param error - Thrown or rejected value.
|
||||
* @param timestamp - Epoch timestamp in milliseconds.
|
||||
* @param stackTrace - Browser call frames attached to the failure.
|
||||
* @returns A wire-safe exception event.
|
||||
*/
|
||||
exceptionEvent(
|
||||
sessionId: ClientRuntimeSessionId,
|
||||
error: unknown,
|
||||
timestamp: number,
|
||||
stackTrace?: RuntimeStackTrace,
|
||||
): RuntimeConsoleBackendEvent<ClientRemoteObjectHandle> | undefined {
|
||||
const session = this.session(sessionId)
|
||||
const allocation = session.beginAllocation()
|
||||
try {
|
||||
const event: RuntimeConsoleBackendEvent<ClientRemoteObjectHandle> = {
|
||||
type: 'exception',
|
||||
event: {
|
||||
timestamp,
|
||||
details: session.describeException(error, 'console', stackTrace, allocation),
|
||||
},
|
||||
}
|
||||
if (!isJsonValue(event) || jsonByteLength(event) + 4_096 > this.limits.maxResponseBytes) {
|
||||
session.rollback(allocation)
|
||||
return undefined
|
||||
}
|
||||
session.commitAllocation(allocation)
|
||||
return event
|
||||
} catch (serializationError) {
|
||||
session.rollback(allocation)
|
||||
throw serializationError
|
||||
}
|
||||
}
|
||||
|
||||
/** Release all sessions when a source generation ends or reconnects. */
|
||||
reset(): void {
|
||||
this.responseAllocations.clear()
|
||||
for (const session of this.sessions.values()) session.close()
|
||||
this.sessions.clear()
|
||||
}
|
||||
|
||||
private session(sessionId: ClientRuntimeSessionId): ClientRuntimeSession {
|
||||
let session = this.sessions.get(sessionId)
|
||||
if (session === undefined) {
|
||||
session = new ClientRuntimeSession(
|
||||
this.limits.maxObjectsPerSession,
|
||||
this.limits.maxPropertiesPerResult,
|
||||
this.resolveScript,
|
||||
)
|
||||
this.sessions.set(sessionId, session)
|
||||
}
|
||||
return session
|
||||
}
|
||||
}
|
||||
|
||||
class ClientRuntimeSession {
|
||||
private readonly objects: ClientObjectStore
|
||||
|
||||
constructor(
|
||||
maxObjects: number,
|
||||
private readonly maxProperties: number,
|
||||
private readonly resolveScript: ClientScriptKeyResolver,
|
||||
) {
|
||||
this.objects = new ClientObjectStore(maxObjects)
|
||||
}
|
||||
|
||||
beginAllocation(): ClientObjectAllocation {
|
||||
return this.objects.beginAllocation()
|
||||
}
|
||||
|
||||
commitAllocation(allocation: ClientObjectAllocation): void {
|
||||
this.objects.commitAllocation(allocation)
|
||||
}
|
||||
|
||||
rollback(allocation: ClientObjectAllocation): void {
|
||||
this.objects.rollback(allocation)
|
||||
}
|
||||
|
||||
async execute(
|
||||
command: ClientRuntimeCommand,
|
||||
allocation: ClientObjectAllocation,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ClientRuntimeResult> {
|
||||
switch (command.op) {
|
||||
case 'evaluate':
|
||||
return { op: command.op, completion: await this.evaluate(command, allocation, signal) }
|
||||
case 'get-properties': {
|
||||
const result = getClientProperties(this.objects, command, this.maxProperties, allocation)
|
||||
return { op: command.op, ...result }
|
||||
}
|
||||
case 'call-function':
|
||||
return { op: command.op, completion: await this.callFunction(command, allocation, signal) }
|
||||
case 'await-promise':
|
||||
return { op: command.op, completion: await this.awaitPromise(command, allocation, signal) }
|
||||
case 'release-object':
|
||||
this.objects.release(command.handle)
|
||||
return { op: command.op }
|
||||
case 'release-object-group':
|
||||
this.releaseObjectGroup(command.objectGroup)
|
||||
return { op: command.op }
|
||||
case 'global-lexical-scope-names':
|
||||
return { op: command.op, names: [] }
|
||||
default:
|
||||
return assertNever(command)
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.objects.clear()
|
||||
}
|
||||
|
||||
releaseObjectGroup(group: string): void {
|
||||
this.objects.releaseGroup(group)
|
||||
}
|
||||
|
||||
serializeAll(
|
||||
values: readonly unknown[],
|
||||
group: string,
|
||||
allocation: ClientObjectAllocation,
|
||||
): ClientRuntimeRemoteObject[] {
|
||||
return values.map(value => this.objects.serialize(value, { group, generatePreview: true }, allocation))
|
||||
}
|
||||
|
||||
describeException(
|
||||
error: unknown,
|
||||
group: string | undefined,
|
||||
stackTrace?: RuntimeStackTrace,
|
||||
allocation?: ClientObjectAllocation,
|
||||
): ClientRuntimeExceptionDetails {
|
||||
const options = { ...(group === undefined ? {} : { group }) }
|
||||
const resolvedStackTrace = stackTrace ?? clientErrorStack(error, this.resolveScript)
|
||||
const firstFrame = resolvedStackTrace?.callFrames[0]
|
||||
return {
|
||||
text: 'Uncaught',
|
||||
lineNumber: firstFrame?.lineNumber ?? 0,
|
||||
columnNumber: firstFrame?.columnNumber ?? 0,
|
||||
...(firstFrame === undefined ? clientUrl() : { url: firstFrame.url }),
|
||||
...(resolvedStackTrace === undefined ? {} : { stackTrace: resolvedStackTrace }),
|
||||
exception: this.objects.serialize(error, options, allocation),
|
||||
}
|
||||
}
|
||||
|
||||
private async evaluate(
|
||||
command: Extract<ClientRuntimeCommand, { op: 'evaluate' }>,
|
||||
allocation: ClientObjectAllocation,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ClientRuntimeCompletion> {
|
||||
let value: unknown
|
||||
try {
|
||||
value = globalThis.eval(command.expression) as unknown
|
||||
if (command.awaitPromise === true) value = await awaitWithCancellation(value, signal, command.timeoutMs)
|
||||
} catch (error) {
|
||||
if (error instanceof ClientRuntimeExecutionError) throw error
|
||||
return this.exception(error, command.objectGroup, allocation)
|
||||
}
|
||||
return this.completion(
|
||||
value,
|
||||
allocation,
|
||||
command.objectGroup,
|
||||
command.generatePreview,
|
||||
command.returnByValue,
|
||||
)
|
||||
}
|
||||
|
||||
private async callFunction(
|
||||
command: Extract<ClientRuntimeCommand, { op: 'call-function' }>,
|
||||
allocation: ClientObjectAllocation,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ClientRuntimeCompletion> {
|
||||
const receiver = command.receiver === undefined ? globalThis : this.objects.get(command.receiver)
|
||||
const inheritedGroup = command.receiver === undefined ? undefined : this.objects.group(command.receiver)
|
||||
const group = command.objectGroup ?? inheritedGroup
|
||||
const args = (command.arguments ?? []).map(argument => this.resolveArgument(argument))
|
||||
let value: unknown
|
||||
try {
|
||||
const fn = globalThis.eval(`(${command.functionDeclaration}\n)`) as unknown
|
||||
if (typeof fn !== 'function') throw new TypeError('functionDeclaration did not evaluate to a function')
|
||||
value = Reflect.apply(fn, receiver, args)
|
||||
if (command.awaitPromise === true) value = await awaitWithCancellation(value, signal)
|
||||
} catch (error) {
|
||||
if (error instanceof ClientRuntimeExecutionError) throw error
|
||||
return this.exception(error, group, allocation)
|
||||
}
|
||||
return this.completion(value, allocation, group, command.generatePreview, command.returnByValue)
|
||||
}
|
||||
|
||||
private async awaitPromise(
|
||||
command: Extract<ClientRuntimeCommand, { op: 'await-promise' }>,
|
||||
allocation: ClientObjectAllocation,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ClientRuntimeCompletion> {
|
||||
const group = this.objects.group(command.promise)
|
||||
let value: unknown
|
||||
try {
|
||||
value = await awaitWithCancellation(this.objects.get(command.promise), signal)
|
||||
} catch (error) {
|
||||
if (error instanceof ClientRuntimeExecutionError) throw error
|
||||
return this.exception(error, group, allocation)
|
||||
}
|
||||
return this.completion(value, allocation, group, command.generatePreview, command.returnByValue)
|
||||
}
|
||||
|
||||
private resolveArgument(argument: ClientCallArgument): unknown {
|
||||
switch (argument.kind) {
|
||||
case 'value': return argument.value
|
||||
case 'object': return this.objects.get(argument.handle)
|
||||
case 'undefined': return undefined
|
||||
case 'unserializable': return parseUnserializable(argument.value)
|
||||
default: return assertNever(argument)
|
||||
}
|
||||
}
|
||||
|
||||
private exception(
|
||||
error: unknown,
|
||||
group: string | undefined,
|
||||
allocation: ClientObjectAllocation,
|
||||
): ClientRuntimeCompletion {
|
||||
const options = { ...(group === undefined ? {} : { group }) }
|
||||
const details = this.describeException(error, group, undefined, allocation)
|
||||
return { result: this.objects.serialize(error, options, allocation), exceptionDetails: details }
|
||||
}
|
||||
|
||||
private completion(
|
||||
value: unknown,
|
||||
allocation: ClientObjectAllocation,
|
||||
group: string | undefined,
|
||||
generatePreview: boolean | undefined,
|
||||
returnByValue: boolean | undefined,
|
||||
): ClientRuntimeCompletion {
|
||||
return {
|
||||
result: this.objects.serialize(value, {
|
||||
...(group === undefined ? {} : { group }),
|
||||
...(generatePreview === undefined ? {} : { generatePreview }),
|
||||
...(returnByValue === undefined ? {} : { returnByValue }),
|
||||
}, allocation),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function responseFrame(
|
||||
request: ClientRuntimeRequestFrame,
|
||||
outcome: ClientRuntimeResponseFrame['outcome'],
|
||||
): ClientRuntimeResponseFrame {
|
||||
return {
|
||||
v: INSPECTOR_PROTOCOL_VERSION,
|
||||
t: 'client-runtime/response',
|
||||
sourceId: request.sourceId,
|
||||
generation: request.generation,
|
||||
sessionId: request.sessionId,
|
||||
requestId: request.requestId,
|
||||
outcome,
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeError(error: unknown): ClientRuntimeError {
|
||||
const code = error instanceof ClientRuntimeExecutionError ? error.code : 'internal-error'
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return { code, message: message.slice(0, MAX_RUNTIME_ERROR_MESSAGE_LENGTH) }
|
||||
}
|
||||
|
||||
function parseUnserializable(value: string): unknown {
|
||||
if (value === 'NaN') return Number.NaN
|
||||
if (value === 'Infinity') return Number.POSITIVE_INFINITY
|
||||
if (value === '-Infinity') return Number.NEGATIVE_INFINITY
|
||||
if (value === '-0') return -0
|
||||
if (/^-?(?:0|[1-9]\d*)n$/u.test(value)) return BigInt(value.slice(0, -1))
|
||||
throw new ClientRuntimeExecutionError('invalid-request', `Unsupported unserializable value ${JSON.stringify(value)}`)
|
||||
}
|
||||
|
||||
function clientUrl(): { readonly url?: string } {
|
||||
const location = Reflect.get(globalThis, 'location') as unknown
|
||||
if (typeof location !== 'object' || location === null) return {}
|
||||
const href = Reflect.get(location, 'href') as unknown
|
||||
return typeof href === 'string' ? { url: href } : {}
|
||||
}
|
||||
|
||||
async function awaitWithCancellation(
|
||||
value: unknown,
|
||||
signal: AbortSignal | undefined,
|
||||
timeoutMs?: number,
|
||||
): Promise<unknown> {
|
||||
if (signal?.aborted === true) throw new ClientRuntimeExecutionError('timeout', 'Client Runtime request was canceled')
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let onAbort: (() => void) | undefined
|
||||
try {
|
||||
const limits: Promise<never>[] = []
|
||||
if (timeoutMs !== undefined) {
|
||||
limits.push(new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new ClientRuntimeExecutionError('timeout', `Client evaluation exceeded ${String(timeoutMs)}ms`))
|
||||
}, timeoutMs)
|
||||
}))
|
||||
}
|
||||
if (signal !== undefined) {
|
||||
limits.push(new Promise<never>((_resolve, reject) => {
|
||||
onAbort = () => { reject(new ClientRuntimeExecutionError('timeout', 'Client Runtime request was canceled')) }
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
}))
|
||||
}
|
||||
return await Promise.race([Promise.resolve(value), ...limits])
|
||||
} finally {
|
||||
if (timer !== undefined) clearTimeout(timer)
|
||||
if (onAbort !== undefined) signal?.removeEventListener('abort', onAbort)
|
||||
}
|
||||
}
|
||||
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`Unexpected Client Runtime variant: ${JSON.stringify(value)}`)
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
/** Browser-side catalog for the Inspector Client bundle and its source map. */
|
||||
|
||||
import { bytesToBase64 } from '@deepseek-ai/dsh-util-crypto'
|
||||
import type {
|
||||
ClientScriptDescriptor,
|
||||
ClientSourceCommand,
|
||||
ClientSourceError,
|
||||
ClientSourceResult,
|
||||
ClientSourcesCapability,
|
||||
} from '../../shared/bridge/messages/sources/index.ts'
|
||||
import { inspectorId } from '../../shared/identity.ts'
|
||||
import type { RuntimeScriptKey } from '../../shared/cdp/ids.ts'
|
||||
|
||||
const PACKAGE_ID = '@deepseek-ai/dsh-experimental-inspector'
|
||||
const CLIENT_SCRIPT_KEY = inspectorId<'RuntimeScriptKey'>('client-bundle', 'scriptKey')
|
||||
|
||||
/**
|
||||
* Describe browser-side source access.
|
||||
* @param available - Whether the Client bundle was discovered.
|
||||
* @returns The Sources capability when this Client discovered its bundle.
|
||||
*/
|
||||
export function sourcesBridgeCapability(available: boolean): ClientSourcesCapability | undefined {
|
||||
return available ? { type: 'client-sources' } : undefined
|
||||
}
|
||||
|
||||
/** One lazily loaded browser script exposed by a Client source catalog. */
|
||||
export interface ClientSourceAsset {
|
||||
readonly scriptKey: RuntimeScriptKey
|
||||
readonly url: string
|
||||
readonly hash: string
|
||||
readonly sourceMapUrl?: string
|
||||
readonly isModule?: boolean
|
||||
loadSource(): Promise<string>
|
||||
loadSourceMap?(): Promise<string | undefined>
|
||||
}
|
||||
|
||||
interface LoadedAsset {
|
||||
readonly asset: ClientSourceAsset
|
||||
source?: Promise<string>
|
||||
sourceBytes?: Promise<Uint8Array>
|
||||
sourceMapBytes?: Promise<Uint8Array | undefined>
|
||||
}
|
||||
|
||||
/** Deliberate error serialized by the Client source transport. */
|
||||
export class ClientSourceCatalogError extends Error {
|
||||
constructor(readonly code: ClientSourceError['code'], message: string) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
/** Executes bounded, read-only operations over Client script assets. */
|
||||
export class ClientSourceCatalog {
|
||||
private readonly assets = new Map<RuntimeScriptKey, LoadedAsset>()
|
||||
|
||||
constructor(assets: readonly ClientSourceAsset[]) {
|
||||
for (const asset of assets) {
|
||||
if (this.assets.has(asset.scriptKey)) {
|
||||
throw new Error(`inspector: duplicate Client script key ${asset.scriptKey}`)
|
||||
}
|
||||
this.assets.set(asset.scriptKey, { asset })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a stack-frame URL to this catalog's local script key.
|
||||
* @param url - Absolute or page-relative stack-frame URL.
|
||||
* @returns The matching script key when the URL belongs to this catalog.
|
||||
*/
|
||||
scriptKeyForUrl(url: string): RuntimeScriptKey | undefined {
|
||||
const normalized = normalizedUrl(url)
|
||||
for (const entry of this.assets.values()) {
|
||||
if (normalizedUrl(entry.asset.url) === normalized) return entry.asset.scriptKey
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute one validated source operation.
|
||||
* @param command - Read-only catalog command.
|
||||
* @param maxContentBytes - Maximum encoded bytes admitted for one asset.
|
||||
* @returns Script metadata or one bounded content chunk.
|
||||
*/
|
||||
async execute(command: ClientSourceCommand, maxContentBytes: number): Promise<ClientSourceResult> {
|
||||
if (command.op === 'list-scripts') {
|
||||
return {
|
||||
op: command.op,
|
||||
scripts: await Promise.all([...this.assets.values()].map(async entry => this.describe(entry, maxContentBytes))),
|
||||
}
|
||||
}
|
||||
const entry = this.assets.get(command.scriptKey)
|
||||
if (entry === undefined) throw new ClientSourceCatalogError('script-not-found', 'Client script is not available')
|
||||
const bytes = command.content === 'source'
|
||||
? await this.sourceBytes(entry, maxContentBytes)
|
||||
: await this.sourceMapBytes(entry, maxContentBytes)
|
||||
if (bytes === undefined) {
|
||||
return {
|
||||
op: command.op,
|
||||
scriptKey: command.scriptKey,
|
||||
content: command.content,
|
||||
available: false,
|
||||
}
|
||||
}
|
||||
if (command.offset > bytes.byteLength) {
|
||||
throw new ClientSourceCatalogError('invalid-request', 'Client source chunk offset exceeds content length')
|
||||
}
|
||||
const nextOffset = Math.min(bytes.byteLength, command.offset + command.maxBytes)
|
||||
return {
|
||||
op: command.op,
|
||||
scriptKey: command.scriptKey,
|
||||
content: command.content,
|
||||
available: true,
|
||||
offset: command.offset,
|
||||
nextOffset,
|
||||
data: bytesToBase64(bytes.subarray(command.offset, nextOffset)),
|
||||
eof: nextOffset === bytes.byteLength,
|
||||
}
|
||||
}
|
||||
|
||||
private async describe(entry: LoadedAsset, maxContentBytes: number): Promise<ClientScriptDescriptor> {
|
||||
const source = await this.source(entry, maxContentBytes)
|
||||
const newline = source.lastIndexOf('\n')
|
||||
return {
|
||||
scriptKey: entry.asset.scriptKey,
|
||||
url: entry.asset.url,
|
||||
hash: entry.asset.hash,
|
||||
buildId: '',
|
||||
...(entry.asset.sourceMapUrl === undefined ? {} : { sourceMapUrl: entry.asset.sourceMapUrl }),
|
||||
startLine: 0,
|
||||
startColumn: 0,
|
||||
endLine: countNewlines(source),
|
||||
endColumn: newline === -1 ? source.length : source.length - newline - 1,
|
||||
...(entry.asset.isModule === undefined ? {} : { isModule: entry.asset.isModule }),
|
||||
length: source.length,
|
||||
}
|
||||
}
|
||||
|
||||
private source(entry: LoadedAsset, maxContentBytes: number): Promise<string> {
|
||||
entry.source ??= entry.asset.loadSource().catch((error: unknown) => {
|
||||
throw new ClientSourceCatalogError('load-failed', `Cannot load Client script: ${renderError(error)}`)
|
||||
})
|
||||
return entry.source.then((source) => {
|
||||
if (new TextEncoder().encode(source).byteLength > maxContentBytes) {
|
||||
throw new ClientSourceCatalogError('result-too-large', 'Client script exceeds the configured content limit')
|
||||
}
|
||||
return source
|
||||
})
|
||||
}
|
||||
|
||||
private sourceBytes(entry: LoadedAsset, maxContentBytes: number): Promise<Uint8Array> {
|
||||
entry.sourceBytes ??= this.source(entry, maxContentBytes).then(source => new TextEncoder().encode(source))
|
||||
return entry.sourceBytes
|
||||
}
|
||||
|
||||
private sourceMapBytes(entry: LoadedAsset, maxContentBytes: number): Promise<Uint8Array | undefined> {
|
||||
if (entry.asset.loadSourceMap === undefined) return Promise.resolve(undefined)
|
||||
entry.sourceMapBytes ??= entry.asset.loadSourceMap().then(value =>
|
||||
value === undefined ? undefined : new TextEncoder().encode(value),
|
||||
).catch((error: unknown) => {
|
||||
throw new ClientSourceCatalogError('load-failed', `Cannot load Client source map: ${renderError(error)}`)
|
||||
})
|
||||
return entry.sourceMapBytes.then((bytes) => {
|
||||
if (bytes !== undefined && bytes.byteLength > maxContentBytes) {
|
||||
throw new ClientSourceCatalogError('result-too-large', 'Client source map exceeds the configured content limit')
|
||||
}
|
||||
return bytes
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover this package's bundle URL from the Host-injected web boot graph.
|
||||
* @returns A lazy catalog, or `undefined` outside the assembled web application.
|
||||
*/
|
||||
export function discoverInspectorClientSourceCatalog(): ClientSourceCatalog | undefined {
|
||||
const graph = Reflect.get(globalThis, '__DSH_BOOT__') as unknown
|
||||
if (typeof graph !== 'object' || graph === null) return undefined
|
||||
const entries = Reflect.get(graph, 'entries') as unknown
|
||||
if (!Array.isArray(entries)) return undefined
|
||||
const row = entries.find((value) => {
|
||||
if (typeof value !== 'object' || value === null) return false
|
||||
return Reflect.get(value, 'id') === PACKAGE_ID
|
||||
}) as Record<string, unknown> | undefined
|
||||
if (row === undefined || typeof row.url !== 'string' || typeof row.rev !== 'string') return undefined
|
||||
const base = browserLocation()
|
||||
if (base === undefined) return undefined
|
||||
const sourceUrl = new URL(row.url, base)
|
||||
const sourceMapUrl = new URL(sourceUrl.href)
|
||||
sourceMapUrl.pathname = `${sourceMapUrl.pathname}.map`
|
||||
return new ClientSourceCatalog([{
|
||||
scriptKey: CLIENT_SCRIPT_KEY,
|
||||
url: sourceUrl.href,
|
||||
hash: row.rev,
|
||||
sourceMapUrl: sourceMapUrl.href,
|
||||
isModule: false,
|
||||
loadSource: async () => fetchText(sourceUrl.href),
|
||||
loadSourceMap: async () => fetchText(sourceMapUrl.href),
|
||||
}])
|
||||
}
|
||||
|
||||
async function fetchText(url: string): Promise<string> {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) throw new Error(`${String(response.status)} ${response.statusText}`)
|
||||
return response.text()
|
||||
}
|
||||
|
||||
function browserLocation(): string | undefined {
|
||||
const location = Reflect.get(globalThis, 'location') as unknown
|
||||
if (typeof location !== 'object' || location === null) return undefined
|
||||
const href = Reflect.get(location, 'href') as unknown
|
||||
return typeof href === 'string' ? href : undefined
|
||||
}
|
||||
|
||||
function countNewlines(value: string): number {
|
||||
let count = 0
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
if (value.charCodeAt(index) === 10) count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
function renderError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function normalizedUrl(value: string): string {
|
||||
try {
|
||||
const url = new URL(value, browserLocation())
|
||||
url.hash = ''
|
||||
return url.href
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/** Browser stack parsing for realm-neutral Runtime and Console events. */
|
||||
|
||||
import type { RuntimeScriptKey } from '../../shared/cdp/ids.ts'
|
||||
import type { RuntimeCallFrame, RuntimeStackTrace } from '../../shared/cdp/index.ts'
|
||||
|
||||
/** Resolve a browser stack-frame URL to a Client catalog script key. */
|
||||
export type ClientScriptKeyResolver = (url: string) => RuntimeScriptKey | undefined
|
||||
|
||||
/**
|
||||
* Capture the caller stack of a wrapped Client Console method.
|
||||
* @param resolveScript - Resolver for Client catalog script keys.
|
||||
* @returns Parsed call frames when the browser supplies a stack.
|
||||
*/
|
||||
export function captureClientConsoleStack(resolveScript: ClientScriptKeyResolver): RuntimeStackTrace | undefined {
|
||||
return parseClientStack(new Error().stack, resolveScript, 3)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the stack attached to an uncaught Client value when available.
|
||||
* @param value - Thrown or rejected value.
|
||||
* @param resolveScript - Resolver for Client catalog script keys.
|
||||
* @returns Parsed call frames when the value has a recognized stack string.
|
||||
*/
|
||||
export function clientErrorStack(
|
||||
value: unknown,
|
||||
resolveScript: ClientScriptKeyResolver = () => undefined,
|
||||
): RuntimeStackTrace | undefined {
|
||||
if (typeof value !== 'object' || value === null) return undefined
|
||||
let stack: unknown
|
||||
try {
|
||||
stack = Reflect.get(value, 'stack') as unknown
|
||||
} catch {
|
||||
// A thrown proxy or stack getter cannot replace the original JavaScript exception.
|
||||
return undefined
|
||||
}
|
||||
return typeof stack === 'string' ? parseClientStack(stack, resolveScript, 0) : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse V8- and Firefox-style textual frames into the common stack model.
|
||||
* @param stack - Browser stack text.
|
||||
* @param resolveScript - Resolver for Client catalog script keys.
|
||||
* @param skipFrames - Parsed observer frames omitted from the result.
|
||||
* @returns Parsed call frames, or `undefined` when none remain.
|
||||
*/
|
||||
export function parseClientStack(
|
||||
stack: string | undefined,
|
||||
resolveScript: ClientScriptKeyResolver,
|
||||
skipFrames: number,
|
||||
): RuntimeStackTrace | undefined {
|
||||
if (stack === undefined) return undefined
|
||||
const frames: RuntimeCallFrame[] = []
|
||||
for (const line of stack.split('\n')) {
|
||||
const frame = parseFrame(line, resolveScript)
|
||||
if (frame !== undefined) frames.push(frame)
|
||||
}
|
||||
const callFrames = frames.slice(skipFrames)
|
||||
return callFrames.length === 0 ? undefined : { callFrames }
|
||||
}
|
||||
|
||||
function parseFrame(line: string, resolveScript: ClientScriptKeyResolver): RuntimeCallFrame | undefined {
|
||||
const chrome = /^\s*at\s+(?:(.*?)\s+\()?(.+):(\d+):(\d+)\)?$/u.exec(line)
|
||||
const firefox = chrome === null ? /^(.*?)@(.+):(\d+):(\d+)$/u.exec(line) : null
|
||||
const match = chrome ?? firefox
|
||||
if (match === null) return undefined
|
||||
const url = match[2]
|
||||
const lineNumber = Number(match[3]) - 1
|
||||
const columnNumber = Number(match[4]) - 1
|
||||
if (url === undefined || !Number.isSafeInteger(lineNumber) || !Number.isSafeInteger(columnNumber)) return undefined
|
||||
const scriptKey = resolveScript(url)
|
||||
return {
|
||||
functionName: match[1] ?? '',
|
||||
...(scriptKey === undefined ? {} : { scriptKey }),
|
||||
url,
|
||||
lineNumber,
|
||||
columnNumber,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
/** Browser Client entry for the experimental Inspector Cordis plugin. */
|
||||
|
||||
export * from './plugin.ts'
|
||||
@@ -0,0 +1,3 @@
|
||||
/** Client entry for the shared Cordis snapshot publisher. */
|
||||
|
||||
export { publishCordisTree } from '../../shared/cordis/publisher.ts'
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Client network observation is not enabled in the current source producer. */
|
||||
|
||||
/** Observation topics published by the Client network adapter. */
|
||||
export const NETWORK_TOPICS: readonly string[] = []
|
||||
@@ -0,0 +1,37 @@
|
||||
/** Stable Client source identity with a fresh descriptor for each WebSocket generation. */
|
||||
|
||||
import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
|
||||
import { inspectorId } from '../../shared/identity.ts'
|
||||
import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts'
|
||||
import { bridgeCapabilities } from '../cdp/index.ts'
|
||||
|
||||
/** Owns one browser realm's stable source id across transport reconnects. */
|
||||
export class ClientRealmSource {
|
||||
/** Logical source id retained across reconnecting transport generations. */
|
||||
readonly sourceId = inspectorId<'InspectorSourceId'>(`client-${randomUUID()}`, 'sourceId')
|
||||
|
||||
constructor(private readonly label: string) {}
|
||||
|
||||
/**
|
||||
* Create the descriptor for one newly admitted transport generation.
|
||||
* @param hasSources - Whether the built Client bundle is available for source reads.
|
||||
* @returns A source descriptor with a fresh generation.
|
||||
*/
|
||||
connect(hasSources: boolean): InspectorSourceDescriptor {
|
||||
return {
|
||||
sourceId: this.sourceId,
|
||||
generation: inspectorId<'InspectorSourceGeneration'>(randomUUID(), 'generation'),
|
||||
kind: 'client',
|
||||
label: this.label,
|
||||
timeOriginMs: performance.timeOrigin,
|
||||
capabilities: bridgeCapabilities(clientOrigin(), hasSources),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clientOrigin(): string {
|
||||
const location = Reflect.get(globalThis, 'location') as unknown
|
||||
if (typeof location !== 'object' || location === null) return ''
|
||||
const origin = Reflect.get(location, 'origin') as unknown
|
||||
return typeof origin === 'string' ? origin : ''
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/** Client Cordis plugin that publishes browser observations directly to the Inspector Worker. */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { parseInspectorClientBootstrap } from '../shared/bridge/control-codec.ts'
|
||||
import { createInspectorService, type InspectorService as SharedInspectorService } from '../shared/service.ts'
|
||||
import { publishCordisTree } from './inspection/cordis.ts'
|
||||
import { startInspectorClient } from './bridge/controller.ts'
|
||||
|
||||
export type { CordisRuntimeTreeReader } from '../shared/cordis/reader.ts'
|
||||
export type {
|
||||
CordisRuntimeConnection,
|
||||
CordisRuntimeContext,
|
||||
CordisRuntimeFiber,
|
||||
CordisRuntimeNode,
|
||||
CordisRuntimeRealm,
|
||||
CordisRuntimeSource,
|
||||
CordisRuntimeTree,
|
||||
} from '../shared/cordis/model.ts'
|
||||
|
||||
/** Client-facing Inspector service backed by the shared implementation. */
|
||||
export interface InspectorService extends SharedInspectorService {}
|
||||
|
||||
declare global {
|
||||
/** Host-injected Inspector Client connection parameters. */
|
||||
var __DSH_INSPECTOR__: unknown
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** Publish Client-realm observations and query the shared Inspector state. */
|
||||
inspector: InspectorService
|
||||
}
|
||||
}
|
||||
|
||||
/** Cordis plugin name shared with the Host face. */
|
||||
export const name = 'experimental-inspector'
|
||||
|
||||
/** This transport root has no Client service dependencies. */
|
||||
export const inject: string[] = []
|
||||
|
||||
/** Mount the Client source and shared `ctx.inspector` publishing API. */
|
||||
export function apply(ctx: Context): void {
|
||||
const injected = globalThis.__DSH_INSPECTOR__
|
||||
if (injected === undefined) {
|
||||
throw new Error('experimental inspector: Host bootstrap is missing')
|
||||
}
|
||||
const bootstrap = parseInspectorClientBootstrap(injected)
|
||||
ctx.effect(() => {
|
||||
const source = startInspectorClient(bootstrap)
|
||||
const disposers: Array<() => unknown> = []
|
||||
try {
|
||||
disposers.push(publishCordisTree(ctx, source, {
|
||||
maxNodes: bootstrap.maxCordisNodes,
|
||||
maxBytes: bootstrap.maxFrameBytes - 4_096,
|
||||
}))
|
||||
disposers.push(ctx.provide('inspector', createInspectorService(source)))
|
||||
} catch (error) {
|
||||
try {
|
||||
disposeInspectorClient(source, disposers)
|
||||
} catch (cleanupError) {
|
||||
ctx.logger.error('experimental-inspector: Client initialization rollback failed', cleanupError)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return () => { disposeInspectorClient(source, disposers) }
|
||||
}, 'experimental-inspector: Client source')
|
||||
}
|
||||
|
||||
function disposeInspectorClient(source: ReturnType<typeof startInspectorClient>, disposers: readonly (() => unknown)[]): void {
|
||||
const failures: unknown[] = []
|
||||
for (const dispose of [...disposers].reverse()) {
|
||||
try {
|
||||
dispose()
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
}
|
||||
try {
|
||||
source.close()
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
if (failures.length > 0) throw new AggregateError(failures, 'experimental-inspector: Client disposal failed')
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
/** Host controller that owns the Inspector Worker and Host observation source. */
|
||||
|
||||
import { randomBytes, randomUUID } from 'node:crypto'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { MessageChannel, Worker, type MessagePort, type WorkerOptions } from 'node:worker_threads'
|
||||
import type { InspectorClientBootstrap, InspectorWorkerBoot, InspectorWorkerConfig } from '../../shared/bridge/messages/control.ts'
|
||||
import { INSPECTOR_PROTOCOL_VERSION } from '../../shared/bridge/version.ts'
|
||||
import type { InspectorConnection } from '../../shared/bridge/publisher.ts'
|
||||
import { installFetchObserver, NETWORK_TOPICS, type FetchObserver } from '../inspection/network.ts'
|
||||
import { HostInspectorSource } from './transport.ts'
|
||||
import { InspectorWorkerLifecycle } from './lifecycle.ts'
|
||||
|
||||
const DEFAULT_MAX_REQUEST_BODY_BYTES = 8 * 1024 * 1024
|
||||
const DEFAULT_MAX_RESPONSE_BODY_BYTES = 32 * 1024 * 1024
|
||||
const DEFAULT_MAX_BODY_CHUNK_BYTES = 48 * 1024
|
||||
const DEFAULT_MAX_JOURNAL_BYTES = 256 * 1024 * 1024
|
||||
const DEFAULT_MAX_RETAINED_REQUESTS = 2_000
|
||||
const DEFAULT_MAX_SOURCE_FRAME_BYTES = 128 * 1024
|
||||
const DEFAULT_MAX_SOURCE_RECORDS_PER_FRAME = 128
|
||||
const DEFAULT_MAX_QUEUED_RECORDS = 2_048
|
||||
const DEFAULT_MAX_QUEUED_BYTES = 16 * 1024 * 1024
|
||||
const DEFAULT_STARTUP_TIMEOUT_MS = 10_000
|
||||
const DEFAULT_STOP_TIMEOUT_MS = 5_000
|
||||
const DEFAULT_CLIENT_RECONNECT_BASE_MS = 250
|
||||
const DEFAULT_CLIENT_RECONNECT_MAX_MS = 5_000
|
||||
const DEFAULT_CLIENT_RUNTIME_TIMEOUT_MS = 30_000
|
||||
const DEFAULT_QUERY_TIMEOUT_MS = 10_000
|
||||
const DEFAULT_MAX_CLIENT_RUNTIME_OBJECTS = 10_000
|
||||
const DEFAULT_MAX_CLIENT_RUNTIME_PROPERTIES = 2_000
|
||||
const DEFAULT_MAX_CLIENT_SOURCE_BYTES = 8 * 1024 * 1024
|
||||
const DEFAULT_MAX_CORDIS_NODES = 2_048
|
||||
const DEFAULT_MAX_DISCONNECTED_CORDIS_TREES = 8
|
||||
|
||||
/** User-facing Host options; every memory and lifecycle bound is configurable. */
|
||||
export interface InspectorOptions {
|
||||
/** Loopback address used by the Worker HTTP and WebSocket endpoint. */
|
||||
readonly host?: '127.0.0.1'
|
||||
/** First port to bind; occupied ports advance until one is available. */
|
||||
readonly port?: number
|
||||
/** Additional exact browser origins admitted to the Client ingest socket. */
|
||||
readonly clientOrigins?: readonly string[]
|
||||
/** Whether to observe calls made through the current global fetch function. */
|
||||
readonly captureFetch?: boolean
|
||||
/** Maximum request-body prefix retained for one fetch. */
|
||||
readonly maxRequestBodyBytes?: number
|
||||
/** Maximum response-body prefix retained for one fetch. */
|
||||
readonly maxResponseBodyBytes?: number
|
||||
/** Maximum raw bytes encoded into one body observation. */
|
||||
readonly maxBodyChunkBytes?: number
|
||||
/** Maximum total request and response body bytes retained by the Worker. */
|
||||
readonly maxJournalBytes?: number
|
||||
/** Maximum active and completed fetch requests retained by the Worker. */
|
||||
readonly maxRetainedRequests?: number
|
||||
/** Maximum encoded bytes accepted in one source transport frame. */
|
||||
readonly maxSourceFrameBytes?: number
|
||||
/** Maximum observation records accepted in one source batch. */
|
||||
readonly maxSourceRecordsPerFrame?: number
|
||||
/** Maximum records waiting in one producer queue. */
|
||||
readonly maxQueuedRecords?: number
|
||||
/** Maximum encoded bytes waiting in one producer queue. */
|
||||
readonly maxQueuedBytes?: number
|
||||
/** Maximum time allowed for the Worker to become ready. */
|
||||
readonly startupTimeoutMs?: number
|
||||
/** Grace period before a stopping Worker is terminated. */
|
||||
readonly stopTimeoutMs?: number
|
||||
/** Initial upper bound for randomized Client reconnect delay. */
|
||||
readonly clientReconnectBaseMs?: number
|
||||
/** Maximum upper bound for randomized Client reconnect delay. */
|
||||
readonly clientReconnectMaxMs?: number
|
||||
/** Deadline for one Worker-to-Client Runtime or Sources request. */
|
||||
readonly clientRuntimeTimeoutMs?: number
|
||||
/** Deadline for one non-CDP semantic query. */
|
||||
readonly queryTimeoutMs?: number
|
||||
/** Maximum live object handles retained per Client Runtime session. */
|
||||
readonly maxClientRuntimeObjects?: number
|
||||
/** Maximum descriptors returned by one Client property request. */
|
||||
readonly maxClientRuntimeProperties?: number
|
||||
/** Maximum encoded bytes read for one Client script or source map. */
|
||||
readonly maxClientSourceBytes?: number
|
||||
/** Maximum Context and Fiber nodes retained in one realm snapshot. */
|
||||
readonly maxCordisNodes?: number
|
||||
/** Disconnected Cordis snapshots retained after their live realm closes. */
|
||||
readonly maxDisconnectedCordisTrees?: number
|
||||
}
|
||||
|
||||
/** Fully resolved options used by one running Inspector. */
|
||||
export interface InspectorSpec {
|
||||
readonly host: '127.0.0.1'
|
||||
readonly port: number
|
||||
readonly clientOrigins: readonly string[]
|
||||
readonly captureFetch: boolean
|
||||
readonly maxRequestBodyBytes: number
|
||||
readonly maxResponseBodyBytes: number
|
||||
readonly maxBodyChunkBytes: number
|
||||
readonly maxJournalBytes: number
|
||||
readonly maxRetainedRequests: number
|
||||
readonly maxSourceFrameBytes: number
|
||||
readonly maxSourceRecordsPerFrame: number
|
||||
readonly maxQueuedRecords: number
|
||||
readonly maxQueuedBytes: number
|
||||
readonly startupTimeoutMs: number
|
||||
readonly stopTimeoutMs: number
|
||||
readonly clientReconnectBaseMs: number
|
||||
readonly clientReconnectMaxMs: number
|
||||
readonly clientRuntimeTimeoutMs: number
|
||||
readonly queryTimeoutMs: number
|
||||
readonly maxClientRuntimeObjects: number
|
||||
readonly maxClientRuntimeProperties: number
|
||||
readonly maxClientSourceBytes: number
|
||||
readonly maxCordisNodes: number
|
||||
readonly maxDisconnectedCordisTrees: number
|
||||
}
|
||||
|
||||
/** Addresses and browser bootstrap of one bound Worker. */
|
||||
export interface InspectorEndpoint {
|
||||
readonly httpUrl: string
|
||||
readonly webSocketDebuggerUrl: string
|
||||
readonly devtoolsFrontendUrl: string
|
||||
readonly client: InspectorClientBootstrap
|
||||
}
|
||||
|
||||
/** Running Host-side Inspector owner. */
|
||||
export interface InspectorHandle {
|
||||
readonly endpoint: InspectorEndpoint
|
||||
readonly source: InspectorConnection
|
||||
/** Stop capture and wait for the Worker to release every socket and V8 session. */
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve and validate all deployment-varying Inspector choices.
|
||||
* @param options - Partial caller configuration.
|
||||
* @returns A complete immutable configuration.
|
||||
*/
|
||||
export function resolveInspectorOptions(options: InspectorOptions = {}): InspectorSpec {
|
||||
const spec: InspectorSpec = {
|
||||
host: options.host ?? '127.0.0.1',
|
||||
port: natural(options.port ?? 0, 'port', true),
|
||||
clientOrigins: [...(options.clientOrigins ?? [])],
|
||||
captureFetch: options.captureFetch ?? true,
|
||||
maxRequestBodyBytes: natural(options.maxRequestBodyBytes ?? DEFAULT_MAX_REQUEST_BODY_BYTES, 'maxRequestBodyBytes'),
|
||||
maxResponseBodyBytes: natural(options.maxResponseBodyBytes ?? DEFAULT_MAX_RESPONSE_BODY_BYTES, 'maxResponseBodyBytes'),
|
||||
maxBodyChunkBytes: natural(options.maxBodyChunkBytes ?? DEFAULT_MAX_BODY_CHUNK_BYTES, 'maxBodyChunkBytes'),
|
||||
maxJournalBytes: natural(options.maxJournalBytes ?? DEFAULT_MAX_JOURNAL_BYTES, 'maxJournalBytes'),
|
||||
maxRetainedRequests: natural(options.maxRetainedRequests ?? DEFAULT_MAX_RETAINED_REQUESTS, 'maxRetainedRequests'),
|
||||
maxSourceFrameBytes: natural(options.maxSourceFrameBytes ?? DEFAULT_MAX_SOURCE_FRAME_BYTES, 'maxSourceFrameBytes'),
|
||||
maxSourceRecordsPerFrame: natural(options.maxSourceRecordsPerFrame ?? DEFAULT_MAX_SOURCE_RECORDS_PER_FRAME, 'maxSourceRecordsPerFrame'),
|
||||
maxQueuedRecords: natural(options.maxQueuedRecords ?? DEFAULT_MAX_QUEUED_RECORDS, 'maxQueuedRecords'),
|
||||
maxQueuedBytes: natural(options.maxQueuedBytes ?? DEFAULT_MAX_QUEUED_BYTES, 'maxQueuedBytes'),
|
||||
startupTimeoutMs: natural(options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS, 'startupTimeoutMs'),
|
||||
stopTimeoutMs: natural(options.stopTimeoutMs ?? DEFAULT_STOP_TIMEOUT_MS, 'stopTimeoutMs'),
|
||||
clientReconnectBaseMs: natural(options.clientReconnectBaseMs ?? DEFAULT_CLIENT_RECONNECT_BASE_MS, 'clientReconnectBaseMs'),
|
||||
clientReconnectMaxMs: natural(options.clientReconnectMaxMs ?? DEFAULT_CLIENT_RECONNECT_MAX_MS, 'clientReconnectMaxMs'),
|
||||
clientRuntimeTimeoutMs: natural(options.clientRuntimeTimeoutMs ?? DEFAULT_CLIENT_RUNTIME_TIMEOUT_MS, 'clientRuntimeTimeoutMs'),
|
||||
queryTimeoutMs: natural(options.queryTimeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS, 'queryTimeoutMs'),
|
||||
maxClientRuntimeObjects: natural(options.maxClientRuntimeObjects ?? DEFAULT_MAX_CLIENT_RUNTIME_OBJECTS, 'maxClientRuntimeObjects'),
|
||||
maxClientRuntimeProperties: natural(options.maxClientRuntimeProperties ?? DEFAULT_MAX_CLIENT_RUNTIME_PROPERTIES, 'maxClientRuntimeProperties'),
|
||||
maxClientSourceBytes: natural(options.maxClientSourceBytes ?? DEFAULT_MAX_CLIENT_SOURCE_BYTES, 'maxClientSourceBytes'),
|
||||
maxCordisNodes: natural(options.maxCordisNodes ?? DEFAULT_MAX_CORDIS_NODES, 'maxCordisNodes'),
|
||||
maxDisconnectedCordisTrees: natural(
|
||||
options.maxDisconnectedCordisTrees ?? DEFAULT_MAX_DISCONNECTED_CORDIS_TREES,
|
||||
'maxDisconnectedCordisTrees',
|
||||
true,
|
||||
),
|
||||
}
|
||||
if (spec.port > 65_535) throw new Error('inspector: port must not exceed 65535')
|
||||
const largestEncodedChunk = Math.ceil(spec.maxBodyChunkBytes / 3) * 4 + 4_096
|
||||
if (largestEncodedChunk > spec.maxSourceFrameBytes) {
|
||||
throw new Error('inspector: maxSourceFrameBytes cannot carry one base64 body chunk')
|
||||
}
|
||||
if (spec.clientReconnectMaxMs < spec.clientReconnectBaseMs) {
|
||||
throw new Error('inspector: clientReconnectMaxMs must be at least clientReconnectBaseMs')
|
||||
}
|
||||
for (const origin of spec.clientOrigins) {
|
||||
if (new URL(origin).origin !== origin) throw new Error(`inspector: client origin must be canonical: ${origin}`)
|
||||
}
|
||||
return spec
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the Worker, create the Host source, and install full fetch capture by default.
|
||||
* @param options - Partial caller configuration.
|
||||
* @returns The ready endpoint and its quiescent shutdown handle.
|
||||
*/
|
||||
export async function startInspector(options: InspectorOptions = {}): Promise<InspectorHandle> {
|
||||
const spec = resolveInspectorOptions(options)
|
||||
const channel = new MessageChannel()
|
||||
const clientProtocol = `dsh-inspector-v${String(INSPECTOR_PROTOCOL_VERSION)}-${randomBytes(32).toString('base64url')}`
|
||||
const config: InspectorWorkerConfig = {
|
||||
host: spec.host,
|
||||
startPort: spec.port,
|
||||
targetId: randomUUID(),
|
||||
clientToken: clientProtocol,
|
||||
clientOrigins: spec.clientOrigins,
|
||||
maxSourceFrameBytes: spec.maxSourceFrameBytes,
|
||||
maxSourceRecordsPerFrame: spec.maxSourceRecordsPerFrame,
|
||||
maxRetainedRequests: spec.maxRetainedRequests,
|
||||
maxJournalBytes: spec.maxJournalBytes,
|
||||
clientRuntimeTimeoutMs: spec.clientRuntimeTimeoutMs,
|
||||
maxClientSourceBytes: spec.maxClientSourceBytes,
|
||||
maxCordisNodes: spec.maxCordisNodes,
|
||||
maxDisconnectedCordisTrees: spec.maxDisconnectedCordisTrees,
|
||||
}
|
||||
const boot: InspectorWorkerBoot<MessagePort> = { config, hostSourcePort: channel.port2 }
|
||||
const worker = spawnWorker(boot)
|
||||
const lifecycle = new InspectorWorkerLifecycle(worker)
|
||||
let source: HostInspectorSource
|
||||
try {
|
||||
source = new HostInspectorSource(channel.port1, {
|
||||
label: 'Host',
|
||||
topics: ['*', ...NETWORK_TOPICS],
|
||||
maxQueuedRecords: spec.maxQueuedRecords,
|
||||
maxQueuedBytes: spec.maxQueuedBytes,
|
||||
maxRecordsPerFrame: spec.maxSourceRecordsPerFrame,
|
||||
maxFrameBytes: spec.maxSourceFrameBytes,
|
||||
queryTimeoutMs: spec.queryTimeoutMs,
|
||||
})
|
||||
} catch (error) {
|
||||
channel.port1.close()
|
||||
await lifecycle.terminate()
|
||||
throw error
|
||||
}
|
||||
|
||||
const ready = await lifecycle.waitForReady(spec.startupTimeoutMs).catch(async (error: unknown) => {
|
||||
source.close()
|
||||
await lifecycle.terminate()
|
||||
throw error
|
||||
})
|
||||
const authority = `${ready.host}:${String(ready.port)}`
|
||||
const endpoint: InspectorEndpoint = {
|
||||
httpUrl: `http://${authority}/`,
|
||||
webSocketDebuggerUrl: `ws://${authority}/devtools/page/${ready.targetId}`,
|
||||
devtoolsFrontendUrl: `devtools://devtools/bundled/devtools_app.html?ws=${authority}/devtools/page/${ready.targetId}&panel=elements&noJavaScriptCompletion=true`,
|
||||
client: {
|
||||
endpoint: `ws://${authority}/ingest`,
|
||||
protocol: clientProtocol,
|
||||
maxQueuedRecords: spec.maxQueuedRecords,
|
||||
maxQueuedBytes: spec.maxQueuedBytes,
|
||||
maxRecordsPerFrame: spec.maxSourceRecordsPerFrame,
|
||||
maxFrameBytes: spec.maxSourceFrameBytes,
|
||||
reconnectBaseMs: spec.clientReconnectBaseMs,
|
||||
reconnectMaxMs: spec.clientReconnectMaxMs,
|
||||
queryTimeoutMs: spec.queryTimeoutMs,
|
||||
maxRuntimeObjectsPerSession: spec.maxClientRuntimeObjects,
|
||||
maxRuntimePropertiesPerResult: spec.maxClientRuntimeProperties,
|
||||
maxClientSourceBytes: spec.maxClientSourceBytes,
|
||||
maxCordisNodes: spec.maxCordisNodes,
|
||||
},
|
||||
}
|
||||
let fetchObserver: FetchObserver | undefined
|
||||
try {
|
||||
fetchObserver = spec.captureFetch
|
||||
? installFetchObserver(source, {
|
||||
maxRequestBodyBytes: spec.maxRequestBodyBytes,
|
||||
maxResponseBodyBytes: spec.maxResponseBodyBytes,
|
||||
maxChunkBytes: spec.maxBodyChunkBytes,
|
||||
})
|
||||
: undefined
|
||||
} catch (error) {
|
||||
source.close()
|
||||
await lifecycle.terminate()
|
||||
throw error
|
||||
}
|
||||
|
||||
lifecycle.markRunning((error) => {
|
||||
try {
|
||||
source.close()
|
||||
} catch (closeError) {
|
||||
console.error('dsh inspector: Host source cleanup after Worker failure failed', closeError)
|
||||
}
|
||||
void fetchObserver?.stop().catch((stopError: unknown) => {
|
||||
console.error('dsh inspector: fetch cleanup after Worker failure failed', stopError)
|
||||
})
|
||||
console.error('dsh inspector: Worker stopped unexpectedly', error)
|
||||
})
|
||||
|
||||
let closing: Promise<void> | undefined
|
||||
return {
|
||||
endpoint,
|
||||
source,
|
||||
close(): Promise<void> {
|
||||
closing ??= closeInspector(lifecycle, source, fetchObserver, spec.stopTimeoutMs)
|
||||
return closing
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function spawnWorker(boot: InspectorWorkerBoot<MessagePort>): Worker {
|
||||
const options: WorkerOptions = {
|
||||
workerData: boot,
|
||||
transferList: [boot.hostSourcePort],
|
||||
execArgv: [],
|
||||
}
|
||||
if (!import.meta.url.endsWith('.ts')) {
|
||||
return new Worker(new URL('./worker.js', import.meta.url), options)
|
||||
}
|
||||
const workerEntry = new URL('../../worker/entry.ts', import.meta.url)
|
||||
const tsxEsmApiEntry = import.meta.resolve('tsx/esm/api')
|
||||
const bootstrap = [
|
||||
`import { register } from ${JSON.stringify(tsxEsmApiEntry)}`,
|
||||
'register()',
|
||||
`await import(${JSON.stringify(workerEntry.href)})`,
|
||||
].join('\n')
|
||||
return new Worker(new URL(`data:text/javascript,${encodeURIComponent(bootstrap)}`), {
|
||||
...options,
|
||||
env: sourceWorkerEnv(),
|
||||
})
|
||||
}
|
||||
|
||||
function sourceWorkerEnv(): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {}
|
||||
if (process.platform === 'win32') {
|
||||
env.TMP = tmpdir()
|
||||
env.TEMP = tmpdir()
|
||||
}
|
||||
if (process.env.TSX_TSCONFIG_PATH !== undefined) env.TSX_TSCONFIG_PATH = process.env.TSX_TSCONFIG_PATH
|
||||
return env
|
||||
}
|
||||
|
||||
async function closeInspector(
|
||||
lifecycle: InspectorWorkerLifecycle,
|
||||
source: HostInspectorSource,
|
||||
fetchObserver: FetchObserver | undefined,
|
||||
timeoutMs: number,
|
||||
): Promise<void> {
|
||||
const failures: unknown[] = []
|
||||
try {
|
||||
await fetchObserver?.stop()
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
try {
|
||||
source.close()
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
try {
|
||||
await lifecycle.stop(timeoutMs)
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
if (failures.length > 0) throw new AggregateError(failures, 'inspector: shutdown failed')
|
||||
}
|
||||
|
||||
function natural(value: number, name: string, zero = false): number {
|
||||
if (!Number.isSafeInteger(value) || value < (zero ? 0 : 1)) {
|
||||
throw new Error(`inspector: ${name} must be ${zero ? 'a non-negative' : 'a positive'} safe integer`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/** Dispatch of validated Worker frames accepted by the Host MessagePort. */
|
||||
|
||||
import type {
|
||||
SourceAcceptedFrame,
|
||||
SourceAppendAcknowledgedFrame,
|
||||
SourceRejectedFrame,
|
||||
SourceResnapshotFrame,
|
||||
WorkerToSourceFrame,
|
||||
} from '../../shared/bridge/messages/observation.ts'
|
||||
import { rejectConsoleBridgeCommand } from '../cdp/console.ts'
|
||||
import { rejectRuntimeBridgeCommand } from '../cdp/runtime.ts'
|
||||
import { rejectSourcesBridgeCommand } from '../cdp/sources.ts'
|
||||
|
||||
/** Operations invoked for source-lifecycle frames addressed to the Host. */
|
||||
export interface HostBridgeFrameHandlers {
|
||||
accepted(frame: SourceAcceptedFrame): void
|
||||
acknowledged(frame: SourceAppendAcknowledgedFrame): void
|
||||
resnapshot(frame: SourceResnapshotFrame): void
|
||||
rejected(frame: SourceRejectedFrame): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch one validated Worker frame and reject Client-only commands on the Host carrier.
|
||||
* @param frame - Decoded Worker-to-source frame.
|
||||
* @param handlers - Host source-lifecycle operations.
|
||||
*/
|
||||
export function dispatchBridgeFrame(frame: WorkerToSourceFrame, handlers: HostBridgeFrameHandlers): void {
|
||||
switch (frame.t) {
|
||||
case 'source/accepted':
|
||||
handlers.accepted(frame)
|
||||
return
|
||||
case 'source/append-acknowledged':
|
||||
handlers.acknowledged(frame)
|
||||
return
|
||||
case 'source/resnapshot':
|
||||
handlers.resnapshot(frame)
|
||||
return
|
||||
case 'source/rejected':
|
||||
handlers.rejected(frame)
|
||||
return
|
||||
case 'client-runtime/request':
|
||||
return rejectRuntimeBridgeCommand(frame.command)
|
||||
case 'client-runtime/cancel':
|
||||
case 'client-runtime/response-acknowledged':
|
||||
return
|
||||
case 'client-console/enable':
|
||||
case 'client-console/disable':
|
||||
return rejectConsoleBridgeCommand(frame.t)
|
||||
case 'client-sources/request':
|
||||
return rejectSourcesBridgeCommand()
|
||||
case 'client-runtime/session-closed':
|
||||
case 'client-sources/session-closed':
|
||||
return
|
||||
default:
|
||||
return assertNever(frame)
|
||||
}
|
||||
}
|
||||
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`Unexpected Worker source frame: ${JSON.stringify(value)}`)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/** Failure containment and shutdown coordination for the Inspector Worker. */
|
||||
|
||||
import type { Worker } from 'node:worker_threads'
|
||||
import type { InspectorHostControl, InspectorWorkerControl } from '../../shared/bridge/messages/control.ts'
|
||||
import { parseInspectorWorkerControl } from '../../shared/bridge/control-codec.ts'
|
||||
|
||||
/** Tracks Worker termination without removing the listener that contains runtime errors. */
|
||||
export class InspectorWorkerLifecycle {
|
||||
private readonly exitResolution = Promise.withResolvers<number>()
|
||||
private readonly failureResolution = Promise.withResolvers<Error>()
|
||||
private failure: Error | undefined
|
||||
private running = false
|
||||
private expectedExit = false
|
||||
private notified = false
|
||||
private onUnexpectedExit: ((error: Error) => void) | undefined
|
||||
private exitCodeValue: number | undefined
|
||||
|
||||
/** Worker exit code once its `exit` event has fired. */
|
||||
get exitCode(): number | undefined {
|
||||
return this.exitCodeValue
|
||||
}
|
||||
|
||||
constructor(private readonly worker: Worker) {
|
||||
worker.on('error', (error) => {
|
||||
this.failure ??= error
|
||||
this.failureResolution.resolve(error)
|
||||
this.notifyUnexpectedExit()
|
||||
})
|
||||
worker.once('exit', (code) => {
|
||||
this.exitCodeValue = code
|
||||
this.exitResolution.resolve(code)
|
||||
this.notifyUnexpectedExit()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the validated ready frame while also observing startup failure and exit.
|
||||
* @param timeoutMs - Readiness deadline in milliseconds.
|
||||
* @returns The Worker's bound endpoint fields.
|
||||
*/
|
||||
async waitForReady(timeoutMs: number): Promise<Extract<InspectorWorkerControl, { type: 'ready' }>> {
|
||||
let timer: NodeJS.Timeout | undefined
|
||||
let onMessage: ((value: unknown) => void) | undefined
|
||||
const message = new Promise<Extract<InspectorWorkerControl, { type: 'ready' }>>((resolve, reject) => {
|
||||
onMessage = (value: unknown): void => {
|
||||
let control: InspectorWorkerControl
|
||||
try {
|
||||
control = parseInspectorWorkerControl(value)
|
||||
} catch (error) {
|
||||
reject(error instanceof Error ? error : new Error(String(error)))
|
||||
return
|
||||
}
|
||||
if (control.type === 'ready') resolve(control)
|
||||
else if (control.type === 'failure') reject(new Error(`inspector Worker failed: ${control.message}`))
|
||||
}
|
||||
timer = setTimeout(() => {
|
||||
reject(new Error(`inspector Worker did not become ready within ${String(timeoutMs)}ms`))
|
||||
}, timeoutMs)
|
||||
this.worker.on('message', onMessage)
|
||||
})
|
||||
try {
|
||||
return await Promise.race([
|
||||
message,
|
||||
this.failureResolution.promise.then((error) => { throw error }),
|
||||
this.exitResolution.promise.then((code) => {
|
||||
throw new Error(`inspector Worker exited before readiness (code ${String(code)})`)
|
||||
}),
|
||||
])
|
||||
} finally {
|
||||
if (timer !== undefined) clearTimeout(timer)
|
||||
if (onMessage !== undefined) this.worker.off('message', onMessage)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin reporting an unexpected runtime exit through one contained callback.
|
||||
* @param listener - Failure observer that must not throw.
|
||||
*/
|
||||
markRunning(listener: (error: Error) => void): void {
|
||||
this.running = true
|
||||
this.onUnexpectedExit = listener
|
||||
this.notifyUnexpectedExit()
|
||||
}
|
||||
|
||||
/** Mark subsequent Worker termination as owner-requested. */
|
||||
expectExit(): void {
|
||||
this.expectedExit = true
|
||||
}
|
||||
|
||||
/** Terminate the Worker during failed initialization. */
|
||||
async terminate(): Promise<void> {
|
||||
this.expectExit()
|
||||
if (this.exitCodeValue === undefined) await this.worker.terminate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Request graceful shutdown and terminate after the deadline.
|
||||
* @param timeoutMs - Grace period before forced termination.
|
||||
*/
|
||||
async stop(timeoutMs: number): Promise<void> {
|
||||
this.expectExit()
|
||||
if (this.exitCodeValue !== undefined) return
|
||||
this.worker.postMessage({ type: 'shutdown' } satisfies InspectorHostControl)
|
||||
let timer: NodeJS.Timeout | undefined
|
||||
const timeout = new Promise<'timeout'>((resolve) => {
|
||||
timer = setTimeout(() => { resolve('timeout') }, timeoutMs)
|
||||
})
|
||||
const outcome = await Promise.race([
|
||||
this.exitResolution.promise.then(() => 'exited' as const),
|
||||
timeout,
|
||||
])
|
||||
if (timer !== undefined) clearTimeout(timer)
|
||||
if (outcome === 'exited') return
|
||||
await this.worker.terminate()
|
||||
throw new Error(`inspector Worker did not stop within ${String(timeoutMs)}ms and was terminated`)
|
||||
}
|
||||
|
||||
private notifyUnexpectedExit(): void {
|
||||
if (!this.running || this.expectedExit || this.notified || this.exitCodeValue === undefined) return
|
||||
this.notified = true
|
||||
this.onUnexpectedExit?.(this.failure ?? new Error(
|
||||
`inspector Worker exited unexpectedly with code ${String(this.exitCodeValue)}`,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/** Buffered Host observation publication over a dedicated Worker MessagePort. */
|
||||
|
||||
import type { MessagePort } from 'node:worker_threads'
|
||||
import { InspectorSourceBuffer, type InspectorSourceBufferOptions } from '../../shared/bridge/buffer.ts'
|
||||
import type { InspectorJsonValue } from '../../shared/json.ts'
|
||||
import type { InspectorStatePublisher } from '../../shared/bridge/publisher.ts'
|
||||
import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts'
|
||||
|
||||
/** Non-blocking Host publisher with microtask-coalesced MessagePort writes. */
|
||||
export class HostBridgePublisher implements InspectorStatePublisher {
|
||||
private readonly records: InspectorSourceBuffer
|
||||
private flushScheduled = false
|
||||
private inFlightNextSequence: number | undefined
|
||||
private closed = false
|
||||
|
||||
constructor(
|
||||
private readonly port: MessagePort,
|
||||
private readonly source: InspectorSourceDescriptor,
|
||||
options: InspectorSourceBufferOptions,
|
||||
) {
|
||||
this.records = new InspectorSourceBuffer(options)
|
||||
}
|
||||
|
||||
publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()): void {
|
||||
if (this.closed) return
|
||||
this.records.publish(topic, payload, monotonicMs)
|
||||
this.scheduleFlush()
|
||||
}
|
||||
|
||||
setState(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()): void {
|
||||
if (this.closed) throw new Error('inspector: Host source is closed')
|
||||
this.records.setState(topic, payload, monotonicMs)
|
||||
this.scheduleFlush()
|
||||
}
|
||||
|
||||
/** Send the retained state as a complete source replacement. */
|
||||
replace(): void {
|
||||
this.inFlightNextSequence = undefined
|
||||
this.port.postMessage(this.records.replacement(this.source.sourceId, this.source.generation))
|
||||
this.scheduleFlush()
|
||||
}
|
||||
|
||||
/** Send one queued batch when no earlier MessagePort batch awaits acknowledgement. */
|
||||
flush(): void {
|
||||
if (this.closed || this.inFlightNextSequence !== undefined) return
|
||||
const frame = this.records.takeBatch(this.source.sourceId, this.source.generation)
|
||||
if (frame === undefined) return
|
||||
this.port.postMessage(frame)
|
||||
this.inFlightNextSequence = frame.firstSequence + frame.records.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Release one in-flight batch and schedule the next bounded transfer.
|
||||
* @param nextSequence - First sequence expected by the Worker after the accepted batch.
|
||||
*/
|
||||
acknowledge(nextSequence: number): void {
|
||||
if (this.closed || this.inFlightNextSequence === undefined) return
|
||||
if (nextSequence !== this.inFlightNextSequence) {
|
||||
throw new Error('inspector: Host source acknowledgement does not match the in-flight batch')
|
||||
}
|
||||
this.inFlightNextSequence = undefined
|
||||
this.scheduleFlush()
|
||||
}
|
||||
|
||||
/** Send at most one final batch, discard later queued observations, and reject publication. */
|
||||
close(): void {
|
||||
if (this.closed) return
|
||||
this.flush()
|
||||
this.closed = true
|
||||
this.records.discardPending()
|
||||
}
|
||||
|
||||
private scheduleFlush(): void {
|
||||
if (!this.records.hasPending || this.flushScheduled) return
|
||||
this.flushScheduled = true
|
||||
queueMicrotask(() => {
|
||||
this.flushScheduled = false
|
||||
this.flush()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Host-side non-CDP query bridge over the Worker MessagePort. */
|
||||
|
||||
import type { MessagePort } from 'node:worker_threads'
|
||||
import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts'
|
||||
import { InspectorQueryConnection, type InspectorQueryConnectionOptions } from '../../shared/bridge/rpc.ts'
|
||||
|
||||
/** Owns query correlation for one Host source generation. */
|
||||
export class HostBridgeRpc extends InspectorQueryConnection {
|
||||
constructor(private readonly port: MessagePort, options: InspectorQueryConnectionOptions) {
|
||||
super(options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect query writes after the Worker accepts the Host source.
|
||||
* @param source - Accepted Host source descriptor.
|
||||
*/
|
||||
connectPort(source: InspectorSourceDescriptor): void {
|
||||
this.connect(source.sourceId, source.generation, {
|
||||
send: (frame) => { this.port.postMessage(frame) },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/** Host-realm observation publisher over a dedicated MessagePort. */
|
||||
|
||||
import type { MessagePort } from 'node:worker_threads'
|
||||
import {
|
||||
INSPECTOR_PROTOCOL_VERSION,
|
||||
parseWorkerSourceFrame,
|
||||
type SourceCloseFrame,
|
||||
type SourceOpenFrame,
|
||||
type WorkerToSourceFrame,
|
||||
} from '../../shared/bridge/messages/observation.ts'
|
||||
import { InspectorSourceConnection } from '../../shared/bridge/publisher.ts'
|
||||
import { createHostRealmSource } from '../inspection/realm.ts'
|
||||
import { HostBridgePublisher } from './publisher.ts'
|
||||
import { HostBridgeRpc } from './rpc.ts'
|
||||
import { dispatchBridgeFrame } from './dispatcher.ts'
|
||||
|
||||
/** Buffer limits for one source publisher. */
|
||||
export interface HostSourceOptions {
|
||||
readonly label: string
|
||||
readonly topics: readonly string[]
|
||||
readonly maxQueuedRecords: number
|
||||
readonly maxQueuedBytes: number
|
||||
readonly maxRecordsPerFrame: number
|
||||
readonly maxFrameBytes: number
|
||||
readonly queryTimeoutMs: number
|
||||
}
|
||||
|
||||
/** Non-blocking Host source; queue overflow is represented by `droppedBefore` on the next batch. */
|
||||
export class HostInspectorSource extends InspectorSourceConnection {
|
||||
private readonly source
|
||||
protected readonly publisher: HostBridgePublisher
|
||||
private closed = false
|
||||
protected readonly queries: HostBridgeRpc
|
||||
|
||||
constructor(private readonly port: MessagePort, options: HostSourceOptions) {
|
||||
super()
|
||||
this.source = createHostRealmSource(options.label)
|
||||
this.publisher = new HostBridgePublisher(port, this.source, options)
|
||||
this.queries = new HostBridgeRpc(port, {
|
||||
timeoutMs: options.queryTimeoutMs,
|
||||
maxFrameBytes: options.maxFrameBytes,
|
||||
})
|
||||
port.on('message', (value: unknown) => {
|
||||
try {
|
||||
if (this.queries.receive(value)) return
|
||||
this.receive(parseWorkerSourceFrame(value))
|
||||
} catch {
|
||||
this.close()
|
||||
}
|
||||
})
|
||||
port.on('close', () => { this.queries.disconnect('Inspector Host source disconnected') })
|
||||
port.start()
|
||||
const open: SourceOpenFrame = {
|
||||
v: INSPECTOR_PROTOCOL_VERSION,
|
||||
t: 'source/open',
|
||||
source: this.source,
|
||||
topics: [...options.topics],
|
||||
}
|
||||
port.postMessage(open)
|
||||
this.publisher.replace()
|
||||
}
|
||||
|
||||
/** Flush pending observations and close the source port. */
|
||||
close(): void {
|
||||
if (this.closed) return
|
||||
this.publisher.close()
|
||||
this.closed = true
|
||||
this.queries.close('Inspector Host source closed')
|
||||
const frame: SourceCloseFrame = {
|
||||
v: INSPECTOR_PROTOCOL_VERSION,
|
||||
t: 'source/close',
|
||||
sourceId: this.source.sourceId,
|
||||
generation: this.source.generation,
|
||||
}
|
||||
this.port.postMessage(frame)
|
||||
this.port.close()
|
||||
}
|
||||
|
||||
private receive(frame: WorkerToSourceFrame): void {
|
||||
if (frame.t !== 'source/rejected'
|
||||
&& (frame.sourceId !== this.source.sourceId || frame.generation !== this.source.generation)) return
|
||||
dispatchBridgeFrame(frame, {
|
||||
accepted: () => { this.queries.connectPort(this.source) },
|
||||
acknowledged: (acknowledged) => { this.publisher.acknowledge(acknowledged.nextSequence) },
|
||||
resnapshot: () => { this.publisher.replace() },
|
||||
rejected: (rejected) => { this.queries.disconnect(`Inspector Host source rejected: ${rejected.message}`) },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/** Host Console is served directly by the Worker-side Node inspector adapter. */
|
||||
|
||||
import type { InspectorSourceCapability } from '../../shared/bridge/messages/observation.ts'
|
||||
|
||||
/**
|
||||
* Describe Host Console transport ownership.
|
||||
* @returns No Host-main-thread Console bridge capability.
|
||||
*/
|
||||
export function consoleBridgeCapability(): InspectorSourceCapability | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a Client Console control frame that was routed to the Host source.
|
||||
* @param operation - Misrouted Console frame type.
|
||||
* @returns This function never returns.
|
||||
*/
|
||||
export function rejectConsoleBridgeCommand(operation: string): never {
|
||||
throw new Error(`inspector protocol: ${operation} cannot use the Host source bridge`)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** Host debugging is served directly by the Worker-side Node inspector adapter. */
|
||||
|
||||
import type { InspectorSourceCapability } from '../../shared/bridge/messages/observation.ts'
|
||||
|
||||
/**
|
||||
* Describe Host debugger transport ownership.
|
||||
* @returns No Host-main-thread Debugger bridge capability.
|
||||
*/
|
||||
export function debuggerBridgeCapability(): InspectorSourceCapability | undefined {
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Explicit failure for Client-style CDP bridge commands misrouted to the Host. */
|
||||
|
||||
import { HOST_CDP_BRIDGE_REASON } from './stack.ts'
|
||||
|
||||
/** Host Runtime uses the Worker-side Node inspector session instead of source RPC. */
|
||||
export class HostCdpBridgeUnavailableError extends Error {
|
||||
constructor(operation: string) {
|
||||
super(`inspector protocol: ${operation} cannot use the Host source bridge; ${HOST_CDP_BRIDGE_REASON}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** Host heap profiling is served directly by the Worker-side Node inspector adapter. */
|
||||
|
||||
import type { InspectorSourceCapability } from '../../shared/bridge/messages/observation.ts'
|
||||
|
||||
/**
|
||||
* Describe Host heap profiler transport ownership.
|
||||
* @returns No Host-main-thread HeapProfiler bridge capability.
|
||||
*/
|
||||
export function heapProfilerBridgeCapability(): InspectorSourceCapability | undefined {
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/** Source-side CDP capability declarations for the Host realm. */
|
||||
|
||||
import type { InspectorSourceCapability } from '../../shared/bridge/messages/observation.ts'
|
||||
import { consoleBridgeCapability } from './console.ts'
|
||||
import { debuggerBridgeCapability } from './debugger.ts'
|
||||
import { heapProfilerBridgeCapability } from './heap-profiler.ts'
|
||||
import { profilerBridgeCapability } from './profiler.ts'
|
||||
import { runtimeBridgeCapability } from './runtime.ts'
|
||||
import { sourcesBridgeCapability } from './sources.ts'
|
||||
|
||||
const HOST_BRIDGE_CAPABILITIES: readonly InspectorSourceCapability[] = [
|
||||
runtimeBridgeCapability(''),
|
||||
consoleBridgeCapability(),
|
||||
sourcesBridgeCapability(false),
|
||||
debuggerBridgeCapability(),
|
||||
profilerBridgeCapability(),
|
||||
heapProfilerBridgeCapability(),
|
||||
].filter((capability): capability is InspectorSourceCapability => capability !== undefined)
|
||||
|
||||
/**
|
||||
* Collect Host source-bridge capabilities.
|
||||
* @param _origin - Unused Host origin supplied for parity with the Client adapter.
|
||||
* @param _hasSources - Unused source availability supplied for parity with the Client adapter.
|
||||
* @returns No capabilities because the Worker attaches to Host V8 directly.
|
||||
*/
|
||||
export function bridgeCapabilities(_origin: string, _hasSources: boolean): readonly InspectorSourceCapability[] {
|
||||
return HOST_BRIDGE_CAPABILITIES
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/** Host RemoteObject handles never cross the Host source bridge. */
|
||||
|
||||
import { HostCdpBridgeUnavailableError } from './errors.ts'
|
||||
|
||||
/**
|
||||
* Reject an object operation that must use the Worker-owned native inspector session.
|
||||
* @param operation - Misrouted object operation.
|
||||
* @returns This function never returns.
|
||||
*/
|
||||
export function rejectObjectBridgeOperation(operation: string): never {
|
||||
throw new HostCdpBridgeUnavailableError(operation)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** Host CPU profiling is served directly by the Worker-side Node inspector adapter. */
|
||||
|
||||
import type { InspectorSourceCapability } from '../../shared/bridge/messages/observation.ts'
|
||||
|
||||
/**
|
||||
* Describe Host CPU profiler transport ownership.
|
||||
* @returns No Host-main-thread Profiler bridge capability.
|
||||
*/
|
||||
export function profilerBridgeCapability(): InspectorSourceCapability | undefined {
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** Host property enumeration never crosses the Host source bridge. */
|
||||
|
||||
import { rejectObjectBridgeOperation } from './objects.ts'
|
||||
|
||||
/**
|
||||
* Reject a property request that must use the Worker-owned native inspector session.
|
||||
* @returns This function never returns.
|
||||
*/
|
||||
export function rejectPropertyBridgeOperation(): never {
|
||||
return rejectObjectBridgeOperation('client-runtime/get-properties')
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/** Host Runtime is served directly by the Worker-side Node inspector adapter. */
|
||||
|
||||
import type { ClientRuntimeCommand } from '../../shared/bridge/messages/runtime/index.ts'
|
||||
import type { InspectorSourceCapability } from '../../shared/bridge/messages/observation.ts'
|
||||
import { HostCdpBridgeUnavailableError } from './errors.ts'
|
||||
import { rejectObjectBridgeOperation } from './objects.ts'
|
||||
import { rejectPropertyBridgeOperation } from './properties.ts'
|
||||
|
||||
/**
|
||||
* Describe Host Runtime transport ownership.
|
||||
* @param _origin - Ignored because Host Runtime does not cross the source bridge.
|
||||
* @returns No Host-main-thread Runtime bridge capability.
|
||||
*/
|
||||
export function runtimeBridgeCapability(_origin: string): InspectorSourceCapability | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a Client Runtime command that was routed to the Host source.
|
||||
* @param command - Misrouted Client Runtime operation.
|
||||
* @returns This function never returns.
|
||||
*/
|
||||
export function rejectRuntimeBridgeCommand(command: ClientRuntimeCommand): never {
|
||||
switch (command.op) {
|
||||
case 'get-properties':
|
||||
return rejectPropertyBridgeOperation()
|
||||
case 'release-object':
|
||||
case 'release-object-group':
|
||||
return rejectObjectBridgeOperation(`client-runtime/${command.op}`)
|
||||
case 'evaluate':
|
||||
case 'call-function':
|
||||
case 'await-promise':
|
||||
case 'global-lexical-scope-names':
|
||||
throw new HostCdpBridgeUnavailableError(`client-runtime/${command.op}`)
|
||||
default:
|
||||
return assertNever(command)
|
||||
}
|
||||
}
|
||||
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`Unexpected Host Runtime bridge command: ${JSON.stringify(value)}`)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/** Host Sources are served directly by the Worker-side Node inspector adapter. */
|
||||
|
||||
import type { InspectorSourceCapability } from '../../shared/bridge/messages/observation.ts'
|
||||
|
||||
/**
|
||||
* Describe Host Sources transport ownership.
|
||||
* @param _available - Ignored because Host Sources do not cross the source bridge.
|
||||
* @returns No Host-main-thread Sources bridge capability.
|
||||
*/
|
||||
export function sourcesBridgeCapability(_available: boolean): InspectorSourceCapability | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a Client Sources request that was routed to the Host source.
|
||||
* @returns This function never returns.
|
||||
*/
|
||||
export function rejectSourcesBridgeCommand(): never {
|
||||
throw new Error('inspector protocol: Client Sources cannot use the Host source bridge')
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Host stack and call-frame data remain owned by the Worker-side Node inspector session. */
|
||||
|
||||
/** Stable explanation used for Host bridge rejections. */
|
||||
export const HOST_CDP_BRIDGE_REASON = 'Host Runtime is attached directly from the Inspector Worker'
|
||||
@@ -0,0 +1,3 @@
|
||||
/** Host entry for the experimental Inspector Cordis plugin and library API. */
|
||||
|
||||
export * from './plugin.ts'
|
||||
@@ -0,0 +1,3 @@
|
||||
/** Host entry for the shared Cordis snapshot publisher. */
|
||||
|
||||
export { publishCordisTree } from '../../shared/cordis/publisher.ts'
|
||||
@@ -0,0 +1,234 @@
|
||||
/** Full `globalThis.fetch` capture that publishes without delaying response delivery. */
|
||||
|
||||
import type { InspectorJsonValue } from '../../shared/json.ts'
|
||||
import type { InspectorPublisher } from '../../shared/bridge/publisher.ts'
|
||||
import { FETCH_TOPICS } from '../../shared/bridge/messages/network.ts'
|
||||
|
||||
/** Observation topics published by the Host network adapter. */
|
||||
export const NETWORK_TOPICS: readonly string[] = FETCH_TOPICS
|
||||
|
||||
/** Byte limits for request and response clone capture. */
|
||||
export interface FetchCaptureOptions {
|
||||
readonly maxRequestBodyBytes: number
|
||||
readonly maxResponseBodyBytes: number
|
||||
readonly maxChunkBytes: number
|
||||
}
|
||||
|
||||
interface CaptureOutcome {
|
||||
readonly capturedBytes: number
|
||||
readonly truncated: boolean
|
||||
readonly captureError?: string
|
||||
}
|
||||
|
||||
/** Active global fetch wrapper. */
|
||||
export interface FetchObserver {
|
||||
/** Restore the prior fetch implementation, cancel clone readers, and await their settlement. */
|
||||
stop(): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Install full fetch capture for every later call through `globalThis.fetch`.
|
||||
* @param publisher - Host source that receives fetch lifecycle records.
|
||||
* @param options - Per-body capture limits.
|
||||
* @returns The owner that stops capture and awaits pending body readers.
|
||||
*/
|
||||
export function installFetchObserver(
|
||||
publisher: InspectorPublisher,
|
||||
options: FetchCaptureOptions,
|
||||
): FetchObserver {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'fetch')
|
||||
const original = globalThis.fetch
|
||||
if (typeof original !== 'function') throw new Error('inspector: globalThis.fetch is unavailable')
|
||||
if (descriptor !== undefined && !('value' in descriptor)) {
|
||||
throw new Error('inspector: globalThis.fetch is an accessor and cannot be observed safely')
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const pending = new Set<Promise<void>>()
|
||||
let nextRequestId = 0
|
||||
|
||||
const track = (promise: Promise<void>): void => {
|
||||
pending.add(promise)
|
||||
void promise.then(
|
||||
() => { pending.delete(promise) },
|
||||
() => { pending.delete(promise) },
|
||||
)
|
||||
}
|
||||
|
||||
const observedFetch: typeof fetch = async (input, init) => {
|
||||
const request = new Request(input, init)
|
||||
const requestId = `fetch-${++nextRequestId}`
|
||||
publisher.publish('fetch/start', {
|
||||
requestId,
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
headers: headerEntries(request.headers),
|
||||
hasBody: request.body !== null,
|
||||
wallTimeMs: Date.now(),
|
||||
})
|
||||
|
||||
let requestClone: Request | undefined
|
||||
try {
|
||||
requestClone = request.clone()
|
||||
} catch (error) {
|
||||
publisher.publish('fetch/request-body-end', {
|
||||
requestId,
|
||||
capturedBytes: 0,
|
||||
truncated: false,
|
||||
captureError: renderError(error),
|
||||
})
|
||||
}
|
||||
if (requestClone !== undefined) {
|
||||
track(captureBody(
|
||||
requestClone.body,
|
||||
options.maxRequestBodyBytes,
|
||||
options.maxChunkBytes,
|
||||
controller.signal,
|
||||
(data) => { publisher.publish('fetch/request-body-chunk', { requestId, data }) },
|
||||
).then((outcome) => {
|
||||
publisher.publish('fetch/request-body-end', compactOutcome(requestId, outcome))
|
||||
}))
|
||||
}
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await Reflect.apply(original, globalThis, [request])
|
||||
} catch (error) {
|
||||
publisher.publish('fetch/error', {
|
||||
requestId,
|
||||
message: renderError(error),
|
||||
canceled: request.signal.aborted || isAbortError(error),
|
||||
})
|
||||
throw error
|
||||
}
|
||||
|
||||
publisher.publish('fetch/response', {
|
||||
requestId,
|
||||
url: response.url || request.url,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: headerEntries(response.headers),
|
||||
mimeType: response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() ?? '',
|
||||
})
|
||||
|
||||
try {
|
||||
const responseClone = response.clone()
|
||||
track(captureBody(
|
||||
responseClone.body,
|
||||
options.maxResponseBodyBytes,
|
||||
options.maxChunkBytes,
|
||||
controller.signal,
|
||||
(data) => { publisher.publish('fetch/response-body-chunk', { requestId, data }) },
|
||||
).then((outcome) => {
|
||||
publisher.publish('fetch/end', {
|
||||
requestId,
|
||||
capturedBytes: outcome.capturedBytes,
|
||||
responseBodyTruncated: outcome.truncated,
|
||||
...(outcome.captureError === undefined ? {} : { responseCaptureError: outcome.captureError }),
|
||||
})
|
||||
}))
|
||||
} catch (error) {
|
||||
publisher.publish('fetch/end', {
|
||||
requestId,
|
||||
capturedBytes: 0,
|
||||
responseBodyTruncated: false,
|
||||
responseCaptureError: renderError(error),
|
||||
})
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
Object.defineProperty(observedFetch, 'name', { value: original.name, configurable: true })
|
||||
Object.defineProperty(observedFetch, 'length', { value: original.length, configurable: true })
|
||||
Object.defineProperty(globalThis, 'fetch', descriptor === undefined
|
||||
? { value: observedFetch, writable: true, configurable: true }
|
||||
: { ...descriptor, value: observedFetch })
|
||||
|
||||
let stopped: Promise<void> | undefined
|
||||
return {
|
||||
stop(): Promise<void> {
|
||||
if (stopped !== undefined) return stopped
|
||||
stopped = (async () => {
|
||||
const current = Object.getOwnPropertyDescriptor(globalThis, 'fetch')
|
||||
if (current !== undefined && 'value' in current && current.value === observedFetch) {
|
||||
if (descriptor === undefined) Reflect.deleteProperty(globalThis, 'fetch')
|
||||
else Object.defineProperty(globalThis, 'fetch', descriptor)
|
||||
}
|
||||
controller.abort()
|
||||
await Promise.allSettled([...pending])
|
||||
})()
|
||||
return stopped
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function captureBody(
|
||||
body: ReadableStream<Uint8Array> | null,
|
||||
limit: number,
|
||||
chunkLimit: number,
|
||||
signal: AbortSignal,
|
||||
emit: (base64: string) => void,
|
||||
): Promise<CaptureOutcome> {
|
||||
if (body === null) return { capturedBytes: 0, truncated: false }
|
||||
const reader = body.getReader()
|
||||
const abort = (): void => { void reader.cancel(signal.reason).catch(() => undefined) }
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
let capturedBytes = 0
|
||||
let truncated = false
|
||||
try {
|
||||
while (!signal.aborted) {
|
||||
const item = await reader.read()
|
||||
if (item.done) break
|
||||
let offset = 0
|
||||
while (offset < item.value.byteLength) {
|
||||
const remaining = limit - capturedBytes
|
||||
if (remaining <= 0) {
|
||||
truncated = true
|
||||
void reader.cancel('inspector body capture limit reached').catch(() => undefined)
|
||||
return { capturedBytes, truncated }
|
||||
}
|
||||
const size = Math.min(chunkLimit, remaining, item.value.byteLength - offset)
|
||||
const chunk = item.value.subarray(offset, offset + size)
|
||||
emit(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength).toString('base64'))
|
||||
capturedBytes += size
|
||||
offset += size
|
||||
}
|
||||
}
|
||||
if (signal.aborted) {
|
||||
void reader.cancel(signal.reason).catch(() => undefined)
|
||||
return { capturedBytes, truncated, captureError: 'inspector stopped during body capture' }
|
||||
}
|
||||
return { capturedBytes, truncated }
|
||||
} catch (error) {
|
||||
return { capturedBytes, truncated: true, captureError: renderError(error) }
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abort)
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
function compactOutcome(requestId: string, outcome: CaptureOutcome): InspectorJsonValue {
|
||||
return {
|
||||
requestId,
|
||||
capturedBytes: outcome.capturedBytes,
|
||||
truncated: outcome.truncated,
|
||||
...(outcome.captureError === undefined ? {} : { captureError: outcome.captureError }),
|
||||
}
|
||||
}
|
||||
|
||||
function headerEntries(headers: Headers): [string, string][] {
|
||||
return [...headers.entries()]
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof DOMException && error.name === 'AbortError'
|
||||
}
|
||||
|
||||
function renderError(error: unknown): string {
|
||||
if (error instanceof Error) return `${error.name}: ${error.message}`
|
||||
try {
|
||||
return String(error)
|
||||
} catch {
|
||||
return 'unrenderable fetch error'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Stable descriptor for the Host observation source generation. */
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { inspectorId } from '../../shared/identity.ts'
|
||||
import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts'
|
||||
import { bridgeCapabilities } from '../cdp/index.ts'
|
||||
|
||||
/**
|
||||
* Create the descriptor for one Host-to-Worker MessagePort generation.
|
||||
* @param label - Human-readable Host execution-context label.
|
||||
* @returns The complete Host source descriptor.
|
||||
*/
|
||||
export function createHostRealmSource(label: string): InspectorSourceDescriptor {
|
||||
return {
|
||||
sourceId: inspectorId<'InspectorSourceId'>(`host-${randomUUID()}`, 'sourceId'),
|
||||
generation: inspectorId<'InspectorSourceGeneration'>(randomUUID(), 'generation'),
|
||||
kind: 'host',
|
||||
label,
|
||||
timeOriginMs: performance.timeOrigin,
|
||||
capabilities: bridgeCapabilities('', false),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/** Host Cordis plugin for the cross-realm Inspector Worker and full fetch capture. */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { resolveInspectorOptions, startInspector, type InspectorOptions } from './bridge/controller.ts'
|
||||
import { createInspectorService } from '../shared/service.ts'
|
||||
import { publishCordisTree } from './inspection/cordis.ts'
|
||||
|
||||
export { resolveInspectorOptions, startInspector } from './bridge/controller.ts'
|
||||
export type { InspectorEndpoint, InspectorHandle, InspectorOptions, InspectorSpec } from './bridge/controller.ts'
|
||||
export type { CordisRuntimeTreeReader } from '../shared/cordis/reader.ts'
|
||||
export type {
|
||||
CordisRuntimeConnection,
|
||||
CordisRuntimeContext,
|
||||
CordisRuntimeFiber,
|
||||
CordisRuntimeNode,
|
||||
CordisRuntimeRealm,
|
||||
CordisRuntimeSource,
|
||||
CordisRuntimeTree,
|
||||
} from '../shared/cordis/model.ts'
|
||||
export type { InspectorClientBootstrap } from '../shared/bridge/messages/control.ts'
|
||||
export type { InspectorRecordInput, InspectorSourceDescriptor, InspectorSourceKind } from '../shared/bridge/messages/observation.ts'
|
||||
export type { InspectorJsonObject, InspectorJsonPrimitive, InspectorJsonValue } from '../shared/json.ts'
|
||||
export type {
|
||||
CordisContextTreeNode,
|
||||
CordisFiberTreeNode,
|
||||
CordisTreeNode,
|
||||
CordisTreeSnapshot,
|
||||
} from '../shared/cordis/snapshot.ts'
|
||||
|
||||
/** Configuration consumed by the Host implementation after package-entry validation. */
|
||||
export interface HostPluginConfig extends Omit<InspectorOptions, 'clientOrigins'> {
|
||||
/** Browser origins allowed to open the Client ingest WebSocket. */
|
||||
clientOrigins?: string[]
|
||||
}
|
||||
|
||||
/** Start the Worker, expose `ctx.inspector`, and inject the matching Client bootstrap. */
|
||||
export async function apply(ctx: Context, config: HostPluginConfig): Promise<void> {
|
||||
await ctx.effect(async () => {
|
||||
const spec = resolveInspectorOptions(config)
|
||||
const handle = await startInspector(spec)
|
||||
const disposers: Array<() => unknown> = []
|
||||
try {
|
||||
disposers.push(publishCordisTree(ctx, handle.source, {
|
||||
maxNodes: spec.maxCordisNodes,
|
||||
maxBytes: spec.maxSourceFrameBytes - 4_096,
|
||||
}))
|
||||
disposers.push(ctx.provide('inspector', createInspectorService(handle.source)))
|
||||
disposers.push(ctx.on('webserver/index-inject', (table: IndexInjection[]) => {
|
||||
table.push({ kind: 'global', name: '__DSH_INSPECTOR__', value: handle.endpoint.client })
|
||||
}))
|
||||
// This readiness URL is emitted while the plugin tree is still loading, before a logger sink is guaranteed.
|
||||
console.log(`dsh inspector: ${handle.endpoint.devtoolsFrontendUrl}`)
|
||||
} catch (error) {
|
||||
await disposeInspector(handle, disposers).catch((cleanupError: unknown) => {
|
||||
ctx.logger.error('experimental-inspector: initialization rollback failed', cleanupError)
|
||||
})
|
||||
throw error
|
||||
}
|
||||
return async () => { await disposeInspector(handle, disposers) }
|
||||
}, 'experimental-inspector: Host Worker')
|
||||
}
|
||||
|
||||
async function disposeInspector(
|
||||
handle: Awaited<ReturnType<typeof startInspector>>,
|
||||
disposers: readonly (() => unknown)[],
|
||||
): Promise<void> {
|
||||
const failures: unknown[] = []
|
||||
for (const dispose of [...disposers].reverse()) {
|
||||
try {
|
||||
await dispose()
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
}
|
||||
try {
|
||||
await handle.close()
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
if (failures.length > 0) throw new AggregateError(failures, 'experimental-inspector: disposal failed')
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/** Repository-facing Host package entry over the mirrored implementation tree. */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import {
|
||||
apply as applyHost,
|
||||
} from './host/plugin.ts'
|
||||
import { resolveInspectorOptions, type InspectorOptions } from './host/bridge/controller.ts'
|
||||
import type { CordisRuntimeTreeReader } from './shared/cordis/reader.ts'
|
||||
import type { InspectorJsonValue } from './shared/json.ts'
|
||||
|
||||
export { resolveInspectorOptions, startInspector } from './host/plugin.ts'
|
||||
export type { InspectorEndpoint, InspectorHandle, InspectorOptions, InspectorSpec } from './host/plugin.ts'
|
||||
export type { CordisRuntimeTreeReader } from './shared/cordis/reader.ts'
|
||||
export type {
|
||||
CordisRuntimeConnection,
|
||||
CordisRuntimeContext,
|
||||
CordisRuntimeFiber,
|
||||
CordisRuntimeNode,
|
||||
CordisRuntimeRealm,
|
||||
CordisRuntimeSource,
|
||||
CordisRuntimeTree,
|
||||
} from './shared/cordis/model.ts'
|
||||
export type { InspectorClientBootstrap } from './shared/bridge/messages/control.ts'
|
||||
export type {
|
||||
InspectorRecordInput,
|
||||
InspectorSourceDescriptor,
|
||||
InspectorSourceKind,
|
||||
} from './shared/bridge/messages/observation.ts'
|
||||
export type { InspectorJsonObject, InspectorJsonPrimitive, InspectorJsonValue } from './shared/json.ts'
|
||||
export type {
|
||||
CordisContextTreeNode,
|
||||
CordisFiberTreeNode,
|
||||
CordisTreeNode,
|
||||
CordisTreeSnapshot,
|
||||
} from './shared/cordis/snapshot.ts'
|
||||
|
||||
/** Shared Host/Client service façade over the realm's source publisher. */
|
||||
export interface InspectorService {
|
||||
/**
|
||||
* Publish one JSON observation without waiting for Worker delivery.
|
||||
* @param topic - Domain-owned topic name.
|
||||
* @param payload - JSON value validated before it reaches the carrier.
|
||||
* @param monotonicMs - Source-clock timestamp; defaults to `performance.now()`.
|
||||
*/
|
||||
publish(topic: string, payload: InspectorJsonValue, monotonicMs?: number): void
|
||||
|
||||
/** Read-only Cordis topology queries independent of CDP sessions. */
|
||||
readonly cordis: CordisRuntimeTreeReader
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** Publish Host-realm observations and query the shared Inspector state. */
|
||||
inspector: InspectorService
|
||||
}
|
||||
}
|
||||
|
||||
/** Cordis plugin name shared with the Client face. */
|
||||
export const name = 'experimental-inspector'
|
||||
|
||||
/** Host service required to inject the Client connection bootstrap into index.html. */
|
||||
export const inject = ['webServer']
|
||||
|
||||
/** Host plugin configuration. Fetch capture is enabled by default. */
|
||||
export interface Config extends Omit<InspectorOptions, 'clientOrigins'> {
|
||||
/** Browser origins allowed to open the Client ingest WebSocket. */
|
||||
clientOrigins?: string[]
|
||||
}
|
||||
|
||||
const libraryDefaults = resolveInspectorOptions()
|
||||
|
||||
/** Runtime validation for {@link Config}. */
|
||||
export const Config: z<Config> = z.object({
|
||||
host: z.const('127.0.0.1').default('127.0.0.1'),
|
||||
port: z.natural().max(65_535).default(9_230),
|
||||
clientOrigins: z.array(z.string()).default([]),
|
||||
captureFetch: z.boolean().default(true),
|
||||
maxRequestBodyBytes: z.natural().min(1).default(libraryDefaults.maxRequestBodyBytes),
|
||||
maxResponseBodyBytes: z.natural().min(1).default(libraryDefaults.maxResponseBodyBytes),
|
||||
maxBodyChunkBytes: z.natural().min(1).default(libraryDefaults.maxBodyChunkBytes),
|
||||
maxJournalBytes: z.natural().min(1).default(libraryDefaults.maxJournalBytes),
|
||||
maxRetainedRequests: z.natural().min(1).default(libraryDefaults.maxRetainedRequests),
|
||||
maxSourceFrameBytes: z.natural().min(1).default(libraryDefaults.maxSourceFrameBytes),
|
||||
maxSourceRecordsPerFrame: z.natural().min(1).default(libraryDefaults.maxSourceRecordsPerFrame),
|
||||
maxQueuedRecords: z.natural().min(1).default(libraryDefaults.maxQueuedRecords),
|
||||
maxQueuedBytes: z.natural().min(1).default(libraryDefaults.maxQueuedBytes),
|
||||
startupTimeoutMs: z.natural().min(1).default(libraryDefaults.startupTimeoutMs),
|
||||
stopTimeoutMs: z.natural().min(1).default(libraryDefaults.stopTimeoutMs),
|
||||
clientReconnectBaseMs: z.natural().min(1).default(libraryDefaults.clientReconnectBaseMs),
|
||||
clientReconnectMaxMs: z.natural().min(1).default(libraryDefaults.clientReconnectMaxMs),
|
||||
clientRuntimeTimeoutMs: z.natural().min(1).default(libraryDefaults.clientRuntimeTimeoutMs),
|
||||
queryTimeoutMs: z.natural().min(1).default(libraryDefaults.queryTimeoutMs),
|
||||
maxClientRuntimeObjects: z.natural().min(1).default(libraryDefaults.maxClientRuntimeObjects),
|
||||
maxClientRuntimeProperties: z.natural().min(1).default(libraryDefaults.maxClientRuntimeProperties),
|
||||
maxClientSourceBytes: z.natural().min(1).default(libraryDefaults.maxClientSourceBytes),
|
||||
maxCordisNodes: z.natural().min(1).default(libraryDefaults.maxCordisNodes),
|
||||
maxDisconnectedCordisTrees: z.natural().default(libraryDefaults.maxDisconnectedCordisTrees),
|
||||
})
|
||||
|
||||
/**
|
||||
* Apply the Host implementation from the repository-standard package entry.
|
||||
* @param ctx - Host Cordis plugin context.
|
||||
* @param config - Validated Inspector configuration.
|
||||
*/
|
||||
export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
await applyHost(ctx, config)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Package-owned invariant companion for the experimental Inspector. */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-experimental-inspector'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'experimental-inspector-invariant'
|
||||
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: wire parsing, generations, Worker lifecycle, and CDP
|
||||
* sessions reject invalid relationships in their owning operations.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/** Register this package's invariant companion. */
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
@@ -0,0 +1,161 @@
|
||||
/** 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.
|
||||
* A record larger than one transport frame is dropped after consuming its sequence number.
|
||||
* @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] as QueuedRecord
|
||||
while (batch.length < this.options.maxRecordsPerFrame && this.queue.length > 0) {
|
||||
const candidate = this.queue[0] as QueuedRecord
|
||||
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
|
||||
}
|
||||
|
||||
/** Discard observations that have not entered a transport frame. */
|
||||
discardPending(): void {
|
||||
this.queue.length = 0
|
||||
this.queuedBytes = 0
|
||||
}
|
||||
|
||||
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() as QueuedRecord
|
||||
this.queuedBytes -= dropped.bytes
|
||||
}
|
||||
}
|
||||
|
||||
private stateFits(): boolean {
|
||||
return jsonByteLength([...this.state.values()] as unknown as InspectorJsonValue) + SOURCE_FRAME_OVERHEAD_BYTES
|
||||
<= this.options.maxFrameBytes
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Bridge-facing exports for lossless JSON values and common wire validators. */
|
||||
|
||||
export * from '../json.ts'
|
||||
export * from '../validation.ts'
|
||||
@@ -0,0 +1,153 @@
|
||||
/** Exact decoders for Host, Worker, and injected Client lifecycle values. */
|
||||
|
||||
import type {
|
||||
InspectorClientBootstrap,
|
||||
InspectorHostControl,
|
||||
InspectorWorkerConfig,
|
||||
InspectorWorkerControl,
|
||||
} from './messages/control.ts'
|
||||
import { isPlainObject } from '../json.ts'
|
||||
import { exactKeys, exactObject } from '../validation.ts'
|
||||
|
||||
/**
|
||||
* Decode the structured-cloned Worker configuration.
|
||||
* @param value - Untrusted workerData config value.
|
||||
* @returns The validated Worker configuration.
|
||||
*/
|
||||
export function parseInspectorWorkerConfig(value: unknown): InspectorWorkerConfig {
|
||||
const record = exactObject(value, [
|
||||
'host', 'startPort', 'targetId', 'clientToken', 'clientOrigins', 'maxSourceFrameBytes',
|
||||
'maxSourceRecordsPerFrame', 'maxRetainedRequests', 'maxJournalBytes', 'clientRuntimeTimeoutMs', 'maxCordisNodes',
|
||||
'maxDisconnectedCordisTrees', 'maxClientSourceBytes',
|
||||
], 'Worker config')
|
||||
if (record.host !== '127.0.0.1') throw new Error('inspector protocol: Worker host must be 127.0.0.1')
|
||||
if (typeof record.targetId !== 'string' || record.targetId.length === 0) {
|
||||
throw new Error('inspector protocol: Worker targetId must be a non-empty string')
|
||||
}
|
||||
if (typeof record.clientToken !== 'string' || record.clientToken.length === 0) {
|
||||
throw new Error('inspector protocol: Worker clientToken must be a non-empty string')
|
||||
}
|
||||
if (!Array.isArray(record.clientOrigins) || !record.clientOrigins.every(origin => typeof origin === 'string')) {
|
||||
throw new Error('inspector protocol: Worker clientOrigins must be strings')
|
||||
}
|
||||
const startPort = natural(record.startPort, 'startPort', true)
|
||||
if (startPort > 65_535) throw new Error('inspector protocol: Worker startPort must not exceed 65535')
|
||||
return {
|
||||
host: record.host,
|
||||
startPort,
|
||||
targetId: record.targetId,
|
||||
clientToken: record.clientToken,
|
||||
clientOrigins: record.clientOrigins,
|
||||
maxSourceFrameBytes: natural(record.maxSourceFrameBytes, 'maxSourceFrameBytes'),
|
||||
maxSourceRecordsPerFrame: natural(record.maxSourceRecordsPerFrame, 'maxSourceRecordsPerFrame'),
|
||||
maxRetainedRequests: natural(record.maxRetainedRequests, 'maxRetainedRequests'),
|
||||
maxJournalBytes: natural(record.maxJournalBytes, 'maxJournalBytes'),
|
||||
clientRuntimeTimeoutMs: natural(record.clientRuntimeTimeoutMs, 'clientRuntimeTimeoutMs'),
|
||||
maxClientSourceBytes: natural(record.maxClientSourceBytes, 'maxClientSourceBytes'),
|
||||
maxCordisNodes: natural(record.maxCordisNodes, 'maxCordisNodes'),
|
||||
maxDisconnectedCordisTrees: natural(record.maxDisconnectedCordisTrees, 'maxDisconnectedCordisTrees', true),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode one Host-to-Worker lifecycle command.
|
||||
* @param value - Untrusted control message.
|
||||
* @returns The validated Host command.
|
||||
*/
|
||||
export function parseInspectorHostControl(value: unknown): InspectorHostControl {
|
||||
const record = exactObject(value, ['type'], 'Host control message')
|
||||
if (record.type !== 'shutdown') throw new Error('inspector protocol: unknown Host control message')
|
||||
return { type: 'shutdown' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode one Worker-to-Host lifecycle event.
|
||||
* @param value - Untrusted control message.
|
||||
* @returns The validated Worker event.
|
||||
*/
|
||||
export function parseInspectorWorkerControl(value: unknown): InspectorWorkerControl {
|
||||
const record = exactObjectByType(value, 'Worker control message')
|
||||
switch (record.type) {
|
||||
case 'ready':
|
||||
exactKeys(record, ['type', 'host', 'port', 'targetId'], 'Worker ready message')
|
||||
if (typeof record.host !== 'string' || typeof record.targetId !== 'string') {
|
||||
throw new Error('inspector protocol: invalid Worker ready identity')
|
||||
}
|
||||
return {
|
||||
type: 'ready',
|
||||
host: record.host,
|
||||
port: natural(record.port, 'port', true),
|
||||
targetId: record.targetId,
|
||||
}
|
||||
case 'failure':
|
||||
exactKeys(record, ['type', 'message'], 'Worker failure message')
|
||||
if (typeof record.message !== 'string') throw new Error('inspector protocol: invalid Worker failure')
|
||||
return { type: 'failure', message: record.message }
|
||||
case 'stopped':
|
||||
exactKeys(record, ['type'], 'Worker stopped message')
|
||||
return { type: 'stopped' }
|
||||
default:
|
||||
throw new Error('inspector protocol: unknown Worker control message')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode bootstrap data injected into the browser global.
|
||||
* @param value - Untrusted injected value.
|
||||
* @returns The validated Client bootstrap.
|
||||
*/
|
||||
export function parseInspectorClientBootstrap(value: unknown): InspectorClientBootstrap {
|
||||
const record = exactObject(value, [
|
||||
'endpoint', 'protocol', 'maxQueuedRecords', 'maxQueuedBytes', 'maxRecordsPerFrame', 'maxFrameBytes',
|
||||
'reconnectBaseMs', 'reconnectMaxMs', 'queryTimeoutMs', 'maxRuntimeObjectsPerSession',
|
||||
'maxRuntimePropertiesPerResult', 'maxCordisNodes', 'maxClientSourceBytes',
|
||||
], 'Client bootstrap')
|
||||
if (typeof record.endpoint !== 'string' || typeof record.protocol !== 'string') {
|
||||
throw new Error('inspector protocol: Client bootstrap endpoint and protocol must be strings')
|
||||
}
|
||||
let endpoint: URL
|
||||
try {
|
||||
endpoint = new URL(record.endpoint)
|
||||
} catch {
|
||||
throw new Error('inspector protocol: Client bootstrap endpoint must be an absolute URL')
|
||||
}
|
||||
if (endpoint.protocol !== 'ws:' || endpoint.hostname !== '127.0.0.1') {
|
||||
throw new Error('inspector protocol: Client bootstrap endpoint must use ws on 127.0.0.1')
|
||||
}
|
||||
if (record.protocol.length === 0 || record.protocol.length > 256) {
|
||||
throw new Error('inspector protocol: Client bootstrap protocol must contain 1 to 256 characters')
|
||||
}
|
||||
const bootstrap: InspectorClientBootstrap = {
|
||||
endpoint: record.endpoint,
|
||||
protocol: record.protocol,
|
||||
maxQueuedRecords: natural(record.maxQueuedRecords, 'maxQueuedRecords'),
|
||||
maxQueuedBytes: natural(record.maxQueuedBytes, 'maxQueuedBytes'),
|
||||
maxRecordsPerFrame: natural(record.maxRecordsPerFrame, 'maxRecordsPerFrame'),
|
||||
maxFrameBytes: natural(record.maxFrameBytes, 'maxFrameBytes'),
|
||||
reconnectBaseMs: natural(record.reconnectBaseMs, 'reconnectBaseMs'),
|
||||
reconnectMaxMs: natural(record.reconnectMaxMs, 'reconnectMaxMs'),
|
||||
queryTimeoutMs: natural(record.queryTimeoutMs, 'queryTimeoutMs'),
|
||||
maxRuntimeObjectsPerSession: natural(record.maxRuntimeObjectsPerSession, 'maxRuntimeObjectsPerSession'),
|
||||
maxRuntimePropertiesPerResult: natural(record.maxRuntimePropertiesPerResult, 'maxRuntimePropertiesPerResult'),
|
||||
maxClientSourceBytes: natural(record.maxClientSourceBytes, 'maxClientSourceBytes'),
|
||||
maxCordisNodes: natural(record.maxCordisNodes, 'maxCordisNodes'),
|
||||
}
|
||||
if (bootstrap.reconnectMaxMs < bootstrap.reconnectBaseMs) {
|
||||
throw new Error('inspector protocol: reconnectMaxMs must be at least reconnectBaseMs')
|
||||
}
|
||||
return bootstrap
|
||||
}
|
||||
|
||||
function exactObjectByType(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!isPlainObject(value) || typeof value.type !== 'string') {
|
||||
throw new Error(`inspector protocol: ${label} must have a type`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function natural(value: unknown, label: string, zero = false): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < (zero ? 0 : 1)) {
|
||||
throw new Error(`inspector protocol: ${label} must be ${zero ? 'a non-negative' : 'a positive'} safe integer`)
|
||||
}
|
||||
return value as number
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/** Opaque identifiers owned by the cross-realm Inspector bridge. */
|
||||
|
||||
import type { InspectorId } from '../identity.ts'
|
||||
|
||||
export { inspectorId } from '../identity.ts'
|
||||
export type { InspectorId } from '../identity.ts'
|
||||
|
||||
/** Stable identity of one logical observation source. */
|
||||
export type InspectorSourceId = InspectorId<'InspectorSourceId'>
|
||||
|
||||
/** Identity of one source connection generation. */
|
||||
export type InspectorSourceGeneration = InspectorId<'InspectorSourceGeneration'>
|
||||
|
||||
/** Identity of one DevTools connection's Client Runtime state. */
|
||||
export type ClientRuntimeSessionId = InspectorId<'ClientRuntimeSessionId'>
|
||||
|
||||
/** Identity of one in-flight Worker-to-Client Runtime operation. */
|
||||
export type ClientRuntimeRequestId = InspectorId<'ClientRuntimeRequestId'>
|
||||
|
||||
/** Identity of one DevTools connection's Client source catalog session. */
|
||||
export type ClientSourceSessionId = InspectorId<'ClientSourceSessionId'>
|
||||
|
||||
/** Identity of one in-flight Worker-to-Client source operation. */
|
||||
export type ClientSourceRequestId = InspectorId<'ClientSourceRequestId'>
|
||||
|
||||
/** Opaque reference to an object retained inside one Client Runtime session. */
|
||||
export type ClientRemoteObjectHandle = InspectorId<'ClientRemoteObjectHandle'>
|
||||
@@ -0,0 +1,72 @@
|
||||
/** Host-to-Worker lifecycle messages and Worker readiness results. */
|
||||
|
||||
/** Fully resolved Worker configuration. */
|
||||
export interface InspectorWorkerConfig {
|
||||
readonly host: '127.0.0.1'
|
||||
/** First port to bind; zero delegates selection to the operating system. */
|
||||
readonly startPort: number
|
||||
readonly targetId: string
|
||||
readonly clientToken: string
|
||||
readonly clientOrigins: readonly string[]
|
||||
readonly maxSourceFrameBytes: number
|
||||
readonly maxSourceRecordsPerFrame: number
|
||||
readonly maxRetainedRequests: number
|
||||
readonly maxJournalBytes: number
|
||||
readonly clientRuntimeTimeoutMs: number
|
||||
readonly maxClientSourceBytes: number
|
||||
readonly maxCordisNodes: number
|
||||
readonly maxDisconnectedCordisTrees: number
|
||||
}
|
||||
|
||||
/** Structured-clone payload used to start the Inspector Worker. */
|
||||
export interface InspectorWorkerBoot<Port> {
|
||||
readonly config: InspectorWorkerConfig
|
||||
readonly hostSourcePort: Port
|
||||
}
|
||||
|
||||
/** Host request to stop accepting traffic and close every Worker-owned resource. */
|
||||
export interface InspectorWorkerShutdown {
|
||||
readonly type: 'shutdown'
|
||||
}
|
||||
|
||||
/** Every control message sent from Host to Worker after boot. */
|
||||
export type InspectorHostControl = InspectorWorkerShutdown
|
||||
|
||||
/** Worker endpoint readiness. */
|
||||
export interface InspectorWorkerReady {
|
||||
readonly type: 'ready'
|
||||
readonly host: string
|
||||
readonly port: number
|
||||
readonly targetId: string
|
||||
}
|
||||
|
||||
/** Worker startup or runtime failure. */
|
||||
export interface InspectorWorkerFailure {
|
||||
readonly type: 'failure'
|
||||
readonly message: string
|
||||
}
|
||||
|
||||
/** Worker completed graceful shutdown. */
|
||||
export interface InspectorWorkerStopped {
|
||||
readonly type: 'stopped'
|
||||
}
|
||||
|
||||
/** Every control message sent from Worker to Host. */
|
||||
export type InspectorWorkerControl = InspectorWorkerReady | InspectorWorkerFailure | InspectorWorkerStopped
|
||||
|
||||
/** Browser bootstrap injected by the Host plugin. */
|
||||
export interface InspectorClientBootstrap {
|
||||
readonly endpoint: string
|
||||
readonly protocol: string
|
||||
readonly maxQueuedRecords: number
|
||||
readonly maxQueuedBytes: number
|
||||
readonly maxRecordsPerFrame: number
|
||||
readonly maxFrameBytes: number
|
||||
readonly reconnectBaseMs: number
|
||||
readonly reconnectMaxMs: number
|
||||
readonly queryTimeoutMs: number
|
||||
readonly maxRuntimeObjectsPerSession: number
|
||||
readonly maxRuntimePropertiesPerResult: number
|
||||
readonly maxClientSourceBytes: number
|
||||
readonly maxCordisNodes: number
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Bridge message metadata for Cordis runtime-tree snapshots. */
|
||||
|
||||
/** Observation topic carrying the latest complete Cordis tree. */
|
||||
export const CORDIS_TREE_TOPIC = 'cordis/tree'
|
||||
@@ -0,0 +1,12 @@
|
||||
/** Observation topic names carried by the internal bridge for captured fetches. */
|
||||
|
||||
/** Complete set of fetch observation topics. */
|
||||
export const FETCH_TOPICS = [
|
||||
'fetch/start',
|
||||
'fetch/request-body-chunk',
|
||||
'fetch/request-body-end',
|
||||
'fetch/response',
|
||||
'fetch/response-body-chunk',
|
||||
'fetch/end',
|
||||
'fetch/error',
|
||||
] as const
|
||||
@@ -0,0 +1,391 @@
|
||||
/** 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,
|
||||
parseClientRuntimeCancelFrame,
|
||||
parseClientRuntimeRequestFrame,
|
||||
parseClientRuntimeResponseAcknowledgedFrame,
|
||||
parseClientRuntimeResponseFrame,
|
||||
parseClientRuntimeSessionClosedFrame,
|
||||
type ClientConsoleCapability,
|
||||
type ClientConsoleDisableFrame,
|
||||
type ClientConsoleEnableFrame,
|
||||
type ClientConsoleEventFrame,
|
||||
type ClientRuntimeCapability,
|
||||
type ClientRuntimeCancelFrame,
|
||||
type ClientRuntimeRequestFrame,
|
||||
type ClientRuntimeResponseAcknowledgedFrame,
|
||||
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 acknowledgement that releases one Host MessagePort batch credit. */
|
||||
export interface SourceAppendAcknowledgedFrame {
|
||||
readonly v: typeof INSPECTOR_PROTOCOL_VERSION
|
||||
readonly t: 'source/append-acknowledged'
|
||||
readonly sourceId: InspectorSourceId
|
||||
readonly generation: InspectorSourceGeneration
|
||||
readonly nextSequence: number
|
||||
}
|
||||
|
||||
/** 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
|
||||
| SourceAppendAcknowledgedFrame
|
||||
| SourceResnapshotFrame
|
||||
| SourceRejectedFrame
|
||||
| ClientConsoleEnableFrame
|
||||
| ClientConsoleDisableFrame
|
||||
| ClientRuntimeCancelFrame
|
||||
| ClientRuntimeRequestFrame
|
||||
| ClientRuntimeResponseAcknowledgedFrame
|
||||
| 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/cancel') return parseClientRuntimeCancelFrame(value)
|
||||
if (value.t === 'client-runtime/response-acknowledged') {
|
||||
return parseClientRuntimeResponseAcknowledgedFrame(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/append-acknowledged') {
|
||||
exactKeys(value, ['v', 't', 'sourceId', 'generation', 'nextSequence'], 'source append acknowledgement')
|
||||
return {
|
||||
...common,
|
||||
t: 'source/append-acknowledged',
|
||||
nextSequence: natural(value.nextSequence, 'nextSequence'),
|
||||
}
|
||||
}
|
||||
if (value.t === 'source/resnapshot'
|
||||
&& typeof value.reason === 'string') {
|
||||
exactKeys(value, ['v', 't', 'sourceId', 'generation', 'expectedSequence', 'reason'], 'source/resnapshot frame')
|
||||
return {
|
||||
...common,
|
||||
t: 'source/resnapshot',
|
||||
expectedSequence: natural(value.expectedSequence, 'expectedSequence'),
|
||||
reason: value.reason,
|
||||
}
|
||||
}
|
||||
throw new Error(`inspector protocol: unknown Worker source frame ${JSON.stringify(value.t)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and rebuild one source frame received at a process or network boundary.
|
||||
* @param value - Untrusted decoded wire value.
|
||||
* @param maxRecords - Maximum records admitted in one frame.
|
||||
* @returns The validated source-to-Worker frame.
|
||||
*/
|
||||
export function parseSourceFrame(value: unknown, maxRecords: number): SourceToWorkerFrame {
|
||||
if (!isJsonValue(value) || !isPlainObject(value)) {
|
||||
throw new Error('inspector protocol: source frame must be a lossless JSON object')
|
||||
}
|
||||
if (value.v !== INSPECTOR_PROTOCOL_VERSION) {
|
||||
throw new Error(`inspector protocol: unsupported version ${JSON.stringify(value.v)}`)
|
||||
}
|
||||
switch (value.t) {
|
||||
case 'source/open':
|
||||
return parseOpen(value)
|
||||
case 'source/replace':
|
||||
return parseRecordsFrame(value, maxRecords, true)
|
||||
case 'source/append':
|
||||
return parseRecordsFrame(value, maxRecords, false)
|
||||
case 'source/close':
|
||||
exactKeys(value, ['v', 't', 'sourceId', 'generation'], 'source/close frame')
|
||||
return {
|
||||
v: INSPECTOR_PROTOCOL_VERSION,
|
||||
t: 'source/close',
|
||||
sourceId: sourceId(value.sourceId),
|
||||
generation: generation(value.generation),
|
||||
}
|
||||
case 'client-runtime/response':
|
||||
return parseClientRuntimeResponseFrame(value)
|
||||
case 'client-console/event':
|
||||
return parseClientConsoleEventFrame(value)
|
||||
case 'client-sources/response':
|
||||
return parseClientSourceResponseFrame(value)
|
||||
default:
|
||||
throw new Error(`inspector protocol: unknown source frame ${JSON.stringify(value.t)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function parseOpen(value: Record<string, unknown>): SourceOpenFrame {
|
||||
exactKeys(value, ['v', 't', 'source', 'topics'], 'source/open frame')
|
||||
if (!isPlainObject(value.source) || !Array.isArray(value.topics)) {
|
||||
throw new Error('inspector protocol: source/open needs source and topics')
|
||||
}
|
||||
const source = value.source
|
||||
exactKeys(source, ['sourceId', 'generation', 'kind', 'label', 'timeOriginMs', 'capabilities'], 'source descriptor')
|
||||
const kind = source.kind
|
||||
if (kind !== 'host' && kind !== 'client') throw new Error('inspector protocol: invalid source kind')
|
||||
if (typeof source.label !== 'string' || source.label.length === 0 || source.label.length > 256) {
|
||||
throw new Error('inspector protocol: source label must contain 1 to 256 characters')
|
||||
}
|
||||
if (typeof source.timeOriginMs !== 'number' || !Number.isFinite(source.timeOriginMs)) {
|
||||
throw new Error('inspector protocol: source timeOriginMs must be finite')
|
||||
}
|
||||
if (!Array.isArray(source.capabilities)) {
|
||||
throw new Error('inspector protocol: source capabilities must be an array')
|
||||
}
|
||||
const capabilities = source.capabilities.map(parseSourceCapability)
|
||||
const capabilityTypes = new Set<string>()
|
||||
for (const capability of capabilities) {
|
||||
if (capabilityTypes.has(capability.type)) {
|
||||
throw new Error(`inspector protocol: source declares ${capability.type} more than once`)
|
||||
}
|
||||
capabilityTypes.add(capability.type)
|
||||
}
|
||||
if (kind !== 'client' && capabilities.length > 0) {
|
||||
throw new Error('inspector protocol: Host sources cannot declare Client capabilities')
|
||||
}
|
||||
const topics = value.topics.map((topic) => {
|
||||
if (typeof topic !== 'string' || topic.length === 0 || topic.length > 128) {
|
||||
throw new Error('inspector protocol: every source topic must contain 1 to 128 characters')
|
||||
}
|
||||
return topic
|
||||
})
|
||||
return {
|
||||
v: INSPECTOR_PROTOCOL_VERSION,
|
||||
t: 'source/open',
|
||||
source: {
|
||||
sourceId: sourceId(source.sourceId),
|
||||
generation: generation(source.generation),
|
||||
kind,
|
||||
label: source.label,
|
||||
timeOriginMs: source.timeOriginMs,
|
||||
capabilities,
|
||||
},
|
||||
topics,
|
||||
}
|
||||
}
|
||||
|
||||
function parseSourceCapability(value: unknown): InspectorSourceCapability {
|
||||
if (!isPlainObject(value) || typeof value.type !== 'string') {
|
||||
throw new Error('inspector protocol: source capability must have a type')
|
||||
}
|
||||
switch (value.type) {
|
||||
case 'client-runtime': return parseClientRuntimeCapability(value)
|
||||
case 'client-console': return parseClientConsoleCapability(value)
|
||||
case 'client-sources': return parseClientSourcesCapability(value)
|
||||
default: throw new Error(`inspector protocol: unknown source capability ${JSON.stringify(value.type)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function parseRecordsFrame(
|
||||
value: Record<string, unknown>,
|
||||
maxRecords: number,
|
||||
replace: boolean,
|
||||
): SourceReplaceFrame | SourceAppendFrame {
|
||||
exactKeys(
|
||||
value,
|
||||
replace
|
||||
? ['v', 't', 'sourceId', 'generation', 'nextSequence', 'records']
|
||||
: ['v', 't', 'sourceId', 'generation', 'firstSequence', 'droppedBefore', 'records'],
|
||||
replace ? 'source/replace frame' : 'source/append frame',
|
||||
)
|
||||
if (!Array.isArray(value.records) || value.records.length > maxRecords) {
|
||||
throw new Error(`inspector protocol: source batch exceeds ${String(maxRecords)} records`)
|
||||
}
|
||||
const records = value.records.map(parseRecord)
|
||||
const common = {
|
||||
v: INSPECTOR_PROTOCOL_VERSION,
|
||||
sourceId: sourceId(value.sourceId),
|
||||
generation: generation(value.generation),
|
||||
records,
|
||||
} as const
|
||||
if (replace) {
|
||||
return {
|
||||
...common,
|
||||
t: 'source/replace',
|
||||
nextSequence: natural(value.nextSequence, 'nextSequence'),
|
||||
}
|
||||
}
|
||||
return {
|
||||
...common,
|
||||
t: 'source/append',
|
||||
firstSequence: natural(value.firstSequence, 'firstSequence'),
|
||||
droppedBefore: natural(value.droppedBefore, 'droppedBefore'),
|
||||
}
|
||||
}
|
||||
|
||||
function parseRecord(value: unknown): InspectorRecordInput {
|
||||
if (!isPlainObject(value)
|
||||
|| typeof value.monotonicMs !== 'number'
|
||||
|| !Number.isFinite(value.monotonicMs)
|
||||
|| typeof value.topic !== 'string'
|
||||
|| value.topic.length === 0
|
||||
|| value.topic.length > 128
|
||||
|| !isJsonValue(value.payload)) {
|
||||
throw new Error('inspector protocol: invalid observation record')
|
||||
}
|
||||
exactKeys(value, ['monotonicMs', 'topic', 'payload'], 'observation record')
|
||||
return { monotonicMs: value.monotonicMs, topic: value.topic, payload: value.payload }
|
||||
}
|
||||
|
||||
function sourceId(value: unknown): InspectorSourceId {
|
||||
if (typeof value !== 'string') throw new Error('inspector protocol: sourceId must be a string')
|
||||
return inspectorId<'InspectorSourceId'>(value, 'sourceId')
|
||||
}
|
||||
|
||||
function generation(value: unknown): InspectorSourceGeneration {
|
||||
if (typeof value !== 'string') throw new Error('inspector protocol: generation must be a string')
|
||||
return inspectorId<'InspectorSourceGeneration'>(value, 'generation')
|
||||
}
|
||||
|
||||
function natural(value: unknown, label: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw new Error(`inspector protocol: ${label} must be a non-negative safe integer`)
|
||||
}
|
||||
return value as number
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/** Exact decoders for non-CDP Inspector query frames. */
|
||||
|
||||
import { parseCordisRuntimeTree } from '../../../cordis/model.ts'
|
||||
import { isPlainObject } from '../../../json.ts'
|
||||
import { exactKeys, exactObject, wireId } from '../../../validation.ts'
|
||||
import { INSPECTOR_PROTOCOL_VERSION } from '../../version.ts'
|
||||
import type { InspectorQuery, InspectorQueryError, InspectorQueryResult } from './commands.ts'
|
||||
import type {
|
||||
InspectorQueryRequestFrame,
|
||||
InspectorQueryRequestId,
|
||||
InspectorQueryResponseFrame,
|
||||
} from './frames.ts'
|
||||
import type { InspectorSourceGeneration, InspectorSourceId } from '../../ids.ts'
|
||||
|
||||
/** Correlation fields recoverable before a query body is accepted. */
|
||||
export interface InspectorQueryFrameIdentity {
|
||||
readonly sourceId: InspectorSourceId
|
||||
readonly generation: InspectorSourceGeneration
|
||||
readonly requestId: InspectorQueryRequestId
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a decoded carrier value belongs to the query request protocol.
|
||||
* @param value - Decoded carrier value.
|
||||
* @returns Whether the query request decoder owns the value.
|
||||
*/
|
||||
export function isInspectorQueryRequestEnvelope(value: unknown): boolean {
|
||||
return isPlainObject(value) && value.t === 'query/request'
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a decoded carrier value belongs to the query response protocol.
|
||||
* @param value - Decoded carrier value.
|
||||
* @returns Whether the query response decoder owns the value.
|
||||
*/
|
||||
export function isInspectorQueryResponseEnvelope(value: unknown): boolean {
|
||||
return isPlainObject(value) && value.t === 'query/response'
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode one source-to-Worker query request.
|
||||
* @param value - Untrusted decoded carrier value.
|
||||
* @returns The detached, validated request frame.
|
||||
*/
|
||||
export function parseInspectorQueryRequestFrame(value: unknown): InspectorQueryRequestFrame {
|
||||
const record = exactObject(value, ['v', 't', 'sourceId', 'generation', 'requestId', 'query'], 'query request')
|
||||
if (record.v !== INSPECTOR_PROTOCOL_VERSION || record.t !== 'query/request') {
|
||||
throw new Error('inspector protocol: invalid query request envelope')
|
||||
}
|
||||
return {
|
||||
v: INSPECTOR_PROTOCOL_VERSION,
|
||||
t: 'query/request',
|
||||
sourceId: wireId<'InspectorSourceId'>(record.sourceId, 'sourceId'),
|
||||
generation: wireId<'InspectorSourceGeneration'>(record.generation, 'generation'),
|
||||
requestId: wireId<'InspectorQueryRequestId'>(record.requestId, 'requestId'),
|
||||
query: parseQuery(record.query),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode correlation fields used to reject a malformed request without timing out its caller.
|
||||
* @param value - Candidate query request frame.
|
||||
* @returns Validated source and request identities.
|
||||
*/
|
||||
export function parseInspectorQueryFrameIdentity(value: unknown): InspectorQueryFrameIdentity {
|
||||
if (!isPlainObject(value) || value.v !== INSPECTOR_PROTOCOL_VERSION || value.t !== 'query/request') {
|
||||
throw new Error('inspector protocol: invalid query request envelope')
|
||||
}
|
||||
return {
|
||||
sourceId: wireId<'InspectorSourceId'>(value.sourceId, 'sourceId'),
|
||||
generation: wireId<'InspectorSourceGeneration'>(value.generation, 'generation'),
|
||||
requestId: wireId<'InspectorQueryRequestId'>(value.requestId, 'requestId'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode one Worker-to-source query response.
|
||||
* @param value - Untrusted decoded carrier value.
|
||||
* @returns The detached, validated response frame.
|
||||
*/
|
||||
export function parseInspectorQueryResponseFrame(value: unknown): InspectorQueryResponseFrame {
|
||||
const record = exactObject(value, ['v', 't', 'sourceId', 'generation', 'requestId', 'outcome'], 'query response')
|
||||
if (record.v !== INSPECTOR_PROTOCOL_VERSION || record.t !== 'query/response') {
|
||||
throw new Error('inspector protocol: invalid query response envelope')
|
||||
}
|
||||
return {
|
||||
v: INSPECTOR_PROTOCOL_VERSION,
|
||||
t: 'query/response',
|
||||
sourceId: wireId<'InspectorSourceId'>(record.sourceId, 'sourceId'),
|
||||
generation: wireId<'InspectorSourceGeneration'>(record.generation, 'generation'),
|
||||
requestId: wireId<'InspectorQueryRequestId'>(record.requestId, 'requestId'),
|
||||
outcome: parseOutcome(record.outcome),
|
||||
}
|
||||
}
|
||||
|
||||
function parseQuery(value: unknown): InspectorQuery {
|
||||
const record = exactObject(value, ['op'], 'Inspector query')
|
||||
if (record.op !== 'cordis-tree/get') {
|
||||
throw new Error(`inspector protocol: unknown query operation ${JSON.stringify(record.op)}`)
|
||||
}
|
||||
return { op: 'cordis-tree/get' }
|
||||
}
|
||||
|
||||
function parseResult(value: unknown): InspectorQueryResult {
|
||||
if (!isPlainObject(value) || typeof value.op !== 'string') {
|
||||
throw new Error('inspector protocol: query result must have an op')
|
||||
}
|
||||
switch (value.op) {
|
||||
case 'cordis-tree/get':
|
||||
exactKeys(value, ['op', 'tree'], 'Cordis tree query result')
|
||||
return { op: 'cordis-tree/get', tree: parseCordisRuntimeTree(value.tree) }
|
||||
default:
|
||||
throw new Error(`inspector protocol: unknown query result ${JSON.stringify(value.op)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function parseOutcome(value: unknown): InspectorQueryResponseFrame['outcome'] {
|
||||
if (!isPlainObject(value) || typeof value.ok !== 'boolean') {
|
||||
throw new Error('inspector protocol: invalid query outcome')
|
||||
}
|
||||
if (value.ok) {
|
||||
exactKeys(value, ['ok', 'result'], 'successful query outcome')
|
||||
return { ok: true, result: parseResult(value.result) }
|
||||
}
|
||||
exactKeys(value, ['ok', 'error'], 'failed query outcome')
|
||||
const error = exactObject(value.error, ['code', 'message'], 'query error')
|
||||
if (!QUERY_ERROR_CODES.has(error.code as InspectorQueryError['code']) || typeof error.message !== 'string') {
|
||||
throw new Error('inspector protocol: invalid query error')
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: error.code as InspectorQueryError['code'], message: error.message },
|
||||
}
|
||||
}
|
||||
|
||||
const QUERY_ERROR_CODES = new Set<InspectorQueryError['code']>([
|
||||
'invalid-request', 'stale-source', 'result-too-large', 'internal-error',
|
||||
])
|
||||
@@ -0,0 +1,40 @@
|
||||
/** Closed non-CDP Inspector query and result model. */
|
||||
|
||||
import type { CordisRuntimeTree } from '../../../cordis/model.ts'
|
||||
|
||||
/** Read the latest committed Cordis runtime tree. */
|
||||
export interface CordisTreeGetQuery {
|
||||
readonly op: 'cordis-tree/get'
|
||||
}
|
||||
|
||||
/** Query operations accepted by the Inspector Worker. */
|
||||
export type InspectorQuery = CordisTreeGetQuery
|
||||
|
||||
/** Result of reading the latest committed Cordis runtime tree. */
|
||||
export interface CordisTreeGetResult {
|
||||
readonly op: 'cordis-tree/get'
|
||||
readonly tree: CordisRuntimeTree
|
||||
}
|
||||
|
||||
/** Results correlated to {@link InspectorQuery} by `op`. */
|
||||
export type InspectorQueryResult = CordisTreeGetResult
|
||||
|
||||
/** Result member corresponding to one query member. */
|
||||
export type InspectorQueryResultFor<Query extends InspectorQuery> = Extract<InspectorQueryResult, { op: Query['op'] }>
|
||||
|
||||
/** Stable Worker-side query failure. */
|
||||
export interface InspectorQueryError {
|
||||
readonly code: 'invalid-request' | 'stale-source' | 'result-too-large' | 'internal-error'
|
||||
readonly message: string
|
||||
}
|
||||
|
||||
/** Host/Client interface implemented by the shared correlated-query owner. */
|
||||
export interface InspectorQueryRequester {
|
||||
/**
|
||||
* Execute one query against the current connected source generation.
|
||||
* @param query - Closed typed query command.
|
||||
* @returns The result with the same operation discriminant.
|
||||
* @throws When transport or Worker processing cannot settle the request successfully.
|
||||
*/
|
||||
request<Query extends InspectorQuery>(query: Query): Promise<InspectorQueryResultFor<Query>>
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Versioned frames for source-to-Worker non-CDP queries. */
|
||||
|
||||
import type { InspectorId, InspectorSourceGeneration, InspectorSourceId } from '../../ids.ts'
|
||||
import { INSPECTOR_PROTOCOL_VERSION } from '../../version.ts'
|
||||
import type { InspectorQuery, InspectorQueryError, InspectorQueryResult } from './commands.ts'
|
||||
|
||||
/** Identity of one in-flight Inspector query. */
|
||||
export type InspectorQueryRequestId = InspectorId<'InspectorQueryRequestId'>
|
||||
|
||||
/** Source request for one Worker-owned query operation. */
|
||||
export interface InspectorQueryRequestFrame {
|
||||
readonly v: typeof INSPECTOR_PROTOCOL_VERSION
|
||||
readonly t: 'query/request'
|
||||
readonly sourceId: InspectorSourceId
|
||||
readonly generation: InspectorSourceGeneration
|
||||
readonly requestId: InspectorQueryRequestId
|
||||
readonly query: InspectorQuery
|
||||
}
|
||||
|
||||
/** Worker response correlated to one source query request. */
|
||||
export interface InspectorQueryResponseFrame {
|
||||
readonly v: typeof INSPECTOR_PROTOCOL_VERSION
|
||||
readonly t: 'query/response'
|
||||
readonly sourceId: InspectorSourceId
|
||||
readonly generation: InspectorSourceGeneration
|
||||
readonly requestId: InspectorQueryRequestId
|
||||
readonly outcome:
|
||||
| { readonly ok: true; readonly result: InspectorQueryResult }
|
||||
| { readonly ok: false; readonly error: InspectorQueryError }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Public exports for the non-CDP Inspector query protocol. */
|
||||
|
||||
export * from './codec.ts'
|
||||
export * from './commands.ts'
|
||||
export * from './frames.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)}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/** Closed command/result protocol for Runtime operations executed by a Client. */
|
||||
|
||||
import type { ClientRemoteObjectHandle } from '../../ids.ts'
|
||||
import type {
|
||||
RuntimeExceptionDetails,
|
||||
RuntimeInternalPropertyDescriptor,
|
||||
RuntimeCallArgument,
|
||||
RuntimeAwaitPromiseRequest,
|
||||
RuntimeCallFunctionRequest,
|
||||
RuntimeCompletion,
|
||||
RuntimeEvaluateRequest,
|
||||
RuntimeGetPropertiesRequest,
|
||||
RuntimePropertyDescriptor,
|
||||
RuntimeRemoteObject,
|
||||
} from '../../../cdp/index.ts'
|
||||
|
||||
/** Runtime object serialized with one Client-session handle when retained. */
|
||||
export type ClientRuntimeRemoteObject = RuntimeRemoteObject<ClientRemoteObjectHandle>
|
||||
|
||||
/** Property descriptor whose retained values use Client-session handles. */
|
||||
export type ClientRuntimePropertyDescriptor = RuntimePropertyDescriptor<ClientRemoteObjectHandle>
|
||||
|
||||
/** Internal property descriptor whose retained values use Client-session handles. */
|
||||
export type ClientRuntimeInternalPropertyDescriptor = RuntimeInternalPropertyDescriptor<ClientRemoteObjectHandle>
|
||||
|
||||
/** Exception details whose retained value uses a Client-session handle. */
|
||||
export type ClientRuntimeExceptionDetails = RuntimeExceptionDetails<ClientRemoteObjectHandle>
|
||||
|
||||
/** One argument supplied to a function in the Client realm. */
|
||||
export type ClientCallArgument = RuntimeCallArgument<ClientRemoteObjectHandle>
|
||||
|
||||
/** Evaluate source text in the Client global execution context. */
|
||||
export interface ClientRuntimeEvaluateCommand extends RuntimeEvaluateRequest {
|
||||
readonly op: 'evaluate'
|
||||
}
|
||||
|
||||
/** Enumerate properties of one retained Client object. */
|
||||
export interface ClientRuntimeGetPropertiesCommand extends RuntimeGetPropertiesRequest<ClientRemoteObjectHandle> {
|
||||
readonly op: 'get-properties'
|
||||
}
|
||||
|
||||
/** Invoke a function declaration with Client-local receivers and arguments. */
|
||||
export interface ClientRuntimeCallFunctionCommand extends RuntimeCallFunctionRequest<ClientRemoteObjectHandle> {
|
||||
readonly op: 'call-function'
|
||||
}
|
||||
|
||||
/** Await one retained Client promise. */
|
||||
export interface ClientRuntimeAwaitPromiseCommand extends RuntimeAwaitPromiseRequest<ClientRemoteObjectHandle> {
|
||||
readonly op: 'await-promise'
|
||||
}
|
||||
|
||||
/** Release one retained Client object. */
|
||||
export interface ClientRuntimeReleaseObjectCommand {
|
||||
readonly op: 'release-object'
|
||||
readonly handle: ClientRemoteObjectHandle
|
||||
}
|
||||
|
||||
/** Release every Client object retained under one DevTools object group. */
|
||||
export interface ClientRuntimeReleaseObjectGroupCommand {
|
||||
readonly op: 'release-object-group'
|
||||
readonly objectGroup: string
|
||||
}
|
||||
|
||||
/** Read names visible in the Client global lexical scope. */
|
||||
export interface ClientRuntimeGlobalLexicalScopeNamesCommand {
|
||||
readonly op: 'global-lexical-scope-names'
|
||||
}
|
||||
|
||||
/** Closed command set implemented by the Client Runtime transport. */
|
||||
export type ClientRuntimeCommand =
|
||||
| ClientRuntimeEvaluateCommand
|
||||
| ClientRuntimeGetPropertiesCommand
|
||||
| ClientRuntimeCallFunctionCommand
|
||||
| ClientRuntimeAwaitPromiseCommand
|
||||
| ClientRuntimeReleaseObjectCommand
|
||||
| ClientRuntimeReleaseObjectGroupCommand
|
||||
| ClientRuntimeGlobalLexicalScopeNamesCommand
|
||||
|
||||
/** Shared result of evaluation, function calls, and promise awaiting. */
|
||||
export type ClientRuntimeCompletion = RuntimeCompletion<ClientRemoteObjectHandle>
|
||||
|
||||
/** Result discriminant mirrors the command and prevents cross-method settlement. */
|
||||
export type ClientRuntimeResult =
|
||||
| { readonly op: 'evaluate'; readonly completion: ClientRuntimeCompletion }
|
||||
| {
|
||||
readonly op: 'get-properties'
|
||||
readonly properties: readonly ClientRuntimePropertyDescriptor[]
|
||||
readonly internalProperties?: readonly ClientRuntimeInternalPropertyDescriptor[]
|
||||
readonly exceptionDetails?: ClientRuntimeExceptionDetails
|
||||
}
|
||||
| { readonly op: 'call-function'; readonly completion: ClientRuntimeCompletion }
|
||||
| { readonly op: 'await-promise'; readonly completion: ClientRuntimeCompletion }
|
||||
| { readonly op: 'release-object' }
|
||||
| { readonly op: 'release-object-group' }
|
||||
| { readonly op: 'global-lexical-scope-names'; readonly names: readonly string[] }
|
||||
|
||||
/** Stable transport-level failures distinct from evaluated JavaScript exceptions. */
|
||||
export interface ClientRuntimeError {
|
||||
readonly code: 'invalid-request' | 'object-not-found' | 'unsupported' | 'timeout' | 'result-too-large' | 'internal-error'
|
||||
readonly message: string
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/** Typed transport for Client Console sessions and events. */
|
||||
|
||||
import type { ClientRemoteObjectHandle, ClientRuntimeSessionId, InspectorSourceGeneration, InspectorSourceId } from '../../ids.ts'
|
||||
import { isPlainObject } from '../../../json.ts'
|
||||
import type { RuntimeConsoleBackendEvent, RuntimeConsoleType } from '../../../cdp/index.ts'
|
||||
import { exactKeys, exactObject, wireId } from '../../../validation.ts'
|
||||
import { INSPECTOR_PROTOCOL_VERSION } from '../../version.ts'
|
||||
import {
|
||||
parseClientRuntimeExceptionDetails,
|
||||
parseClientRuntimeRemoteObject,
|
||||
parseClientRuntimeStackTrace,
|
||||
} from './value-codec.ts'
|
||||
|
||||
/** Source capability that permits Client Console event forwarding. */
|
||||
export interface ClientConsoleCapability {
|
||||
readonly type: 'client-console'
|
||||
}
|
||||
|
||||
/** Worker request to start Console observation for one DevTools session. */
|
||||
export interface ClientConsoleEnableFrame {
|
||||
readonly v: typeof INSPECTOR_PROTOCOL_VERSION
|
||||
readonly t: 'client-console/enable'
|
||||
readonly sourceId: InspectorSourceId
|
||||
readonly generation: InspectorSourceGeneration
|
||||
readonly sessionId: ClientRuntimeSessionId
|
||||
}
|
||||
|
||||
/** Worker request to stop Console observation for one DevTools session. */
|
||||
export interface ClientConsoleDisableFrame {
|
||||
readonly v: typeof INSPECTOR_PROTOCOL_VERSION
|
||||
readonly t: 'client-console/disable'
|
||||
readonly sourceId: InspectorSourceId
|
||||
readonly generation: InspectorSourceGeneration
|
||||
readonly sessionId: ClientRuntimeSessionId
|
||||
}
|
||||
|
||||
/** Client Console event carrying objects retained for one DevTools session. */
|
||||
export interface ClientConsoleEventFrame {
|
||||
readonly v: typeof INSPECTOR_PROTOCOL_VERSION
|
||||
readonly t: 'client-console/event'
|
||||
readonly sourceId: InspectorSourceId
|
||||
readonly generation: InspectorSourceGeneration
|
||||
readonly sessionId: ClientRuntimeSessionId
|
||||
readonly event: RuntimeConsoleBackendEvent<ClientRemoteObjectHandle>
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the marker capability for Client Console forwarding.
|
||||
* @param value - Untrusted capability declaration.
|
||||
* @returns The validated marker capability.
|
||||
*/
|
||||
export function parseClientConsoleCapability(value: unknown): ClientConsoleCapability {
|
||||
const record = exactObject(value, ['type'], 'Client Console capability')
|
||||
if (record.type !== 'client-console') throw new Error('inspector protocol: invalid Client Console capability')
|
||||
return { type: 'client-console' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Worker-to-Client Console lifecycle frame.
|
||||
* @param value - Untrusted decoded frame.
|
||||
* @returns A validated enable or disable frame.
|
||||
*/
|
||||
export function parseClientConsoleControlFrame(
|
||||
value: Record<string, unknown>,
|
||||
): ClientConsoleEnableFrame | ClientConsoleDisableFrame {
|
||||
exactKeys(value, ['v', 't', 'sourceId', 'generation', 'sessionId'], 'Client Console control frame')
|
||||
if (value.v !== INSPECTOR_PROTOCOL_VERSION
|
||||
|| (value.t !== 'client-console/enable' && value.t !== 'client-console/disable')) {
|
||||
throw new Error('inspector protocol: invalid Client Console control frame')
|
||||
}
|
||||
return {
|
||||
v: INSPECTOR_PROTOCOL_VERSION,
|
||||
t: value.t,
|
||||
sourceId: wireId<'InspectorSourceId'>(value.sourceId, 'sourceId'),
|
||||
generation: wireId<'InspectorSourceGeneration'>(value.generation, 'generation'),
|
||||
sessionId: wireId<'ClientRuntimeSessionId'>(value.sessionId, 'sessionId'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one Client-to-Worker Console event.
|
||||
* @param value - Untrusted decoded frame.
|
||||
* @returns A validated Console event frame.
|
||||
*/
|
||||
export function parseClientConsoleEventFrame(value: Record<string, unknown>): ClientConsoleEventFrame {
|
||||
exactKeys(value, ['v', 't', 'sourceId', 'generation', 'sessionId', 'event'], 'Client Console event frame')
|
||||
if (value.v !== INSPECTOR_PROTOCOL_VERSION || value.t !== 'client-console/event') {
|
||||
throw new Error('inspector protocol: invalid Client Console event envelope')
|
||||
}
|
||||
return {
|
||||
v: INSPECTOR_PROTOCOL_VERSION,
|
||||
t: 'client-console/event',
|
||||
sourceId: wireId<'InspectorSourceId'>(value.sourceId, 'sourceId'),
|
||||
generation: wireId<'InspectorSourceGeneration'>(value.generation, 'generation'),
|
||||
sessionId: wireId<'ClientRuntimeSessionId'>(value.sessionId, 'sessionId'),
|
||||
event: parseEvent(value.event),
|
||||
}
|
||||
}
|
||||
|
||||
function parseEvent(value: unknown): RuntimeConsoleBackendEvent<ClientRemoteObjectHandle> {
|
||||
if (!isPlainObject(value) || (value.type !== 'console-api' && value.type !== 'exception')) {
|
||||
throw new Error('inspector protocol: invalid Client Console event')
|
||||
}
|
||||
if (value.type === 'console-api') {
|
||||
exactKeys(value, ['type', 'event'], 'Client Console API event')
|
||||
const event = exactObject(value.event, ['type', 'arguments', 'timestamp', 'contextId', 'stackTrace'], 'Console API event')
|
||||
if (!CONSOLE_TYPES.has(event.type as RuntimeConsoleType)
|
||||
|| !Array.isArray(event.arguments)
|
||||
|| typeof event.timestamp !== 'number'
|
||||
|| !Number.isFinite(event.timestamp)) {
|
||||
throw new Error('inspector protocol: invalid Console API event')
|
||||
}
|
||||
return {
|
||||
type: 'console-api',
|
||||
event: {
|
||||
type: event.type as RuntimeConsoleType,
|
||||
arguments: event.arguments.map(parseClientRuntimeRemoteObject),
|
||||
timestamp: event.timestamp,
|
||||
...(event.contextId === undefined ? {} : { contextId: integer(event.contextId, 'contextId') }),
|
||||
...(event.stackTrace === undefined ? {} : { stackTrace: parseClientRuntimeStackTrace(event.stackTrace) }),
|
||||
},
|
||||
}
|
||||
}
|
||||
exactKeys(value, ['type', 'event'], 'Client exception event')
|
||||
const event = exactObject(value.event, ['timestamp', 'contextId', 'details'], 'Client exception event payload')
|
||||
if (typeof event.timestamp !== 'number' || !Number.isFinite(event.timestamp)) {
|
||||
throw new Error('inspector protocol: invalid Client exception timestamp')
|
||||
}
|
||||
return {
|
||||
type: 'exception',
|
||||
event: {
|
||||
timestamp: event.timestamp,
|
||||
...(event.contextId === undefined ? {} : { contextId: integer(event.contextId, 'contextId') }),
|
||||
details: parseClientRuntimeExceptionDetails(event.details),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
if (!Number.isSafeInteger(value)) throw new Error(`inspector protocol: ${label} must be an integer`)
|
||||
return value as number
|
||||
}
|
||||
|
||||
const CONSOLE_TYPES = new Set<RuntimeConsoleType>([
|
||||
'log', 'debug', 'info', 'error', 'warning', 'dir', 'dirxml', 'table', 'trace', 'clear',
|
||||
'startGroup', 'startGroupCollapsed', 'endGroup', 'assert', 'profile', 'profileEnd', 'count', 'timeEnd',
|
||||
])
|
||||
@@ -0,0 +1,213 @@
|
||||
/** 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
|
||||
}
|
||||
|
||||
/** Worker cancellation of one outstanding Client Runtime request. */
|
||||
export interface ClientRuntimeCancelFrame {
|
||||
readonly v: typeof INSPECTOR_PROTOCOL_VERSION
|
||||
readonly t: 'client-runtime/cancel'
|
||||
readonly sourceId: InspectorSourceId
|
||||
readonly generation: InspectorSourceGeneration
|
||||
readonly sessionId: ClientRuntimeSessionId
|
||||
readonly requestId: ClientRuntimeRequestId
|
||||
}
|
||||
|
||||
/** Worker acknowledgement that commits one successful Client Runtime response. */
|
||||
export interface ClientRuntimeResponseAcknowledgedFrame {
|
||||
readonly v: typeof INSPECTOR_PROTOCOL_VERSION
|
||||
readonly t: 'client-runtime/response-acknowledged'
|
||||
readonly sourceId: InspectorSourceId
|
||||
readonly generation: InspectorSourceGeneration
|
||||
readonly sessionId: ClientRuntimeSessionId
|
||||
readonly requestId: ClientRuntimeRequestId
|
||||
}
|
||||
|
||||
/** 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 Worker-to-Client Runtime cancellation.
|
||||
* @param value - Untrusted cancellation frame.
|
||||
* @returns The validated cancellation frame.
|
||||
*/
|
||||
export function parseClientRuntimeCancelFrame(value: Record<string, unknown>): ClientRuntimeCancelFrame {
|
||||
exactKeys(value, ['v', 't', 'sourceId', 'generation', 'sessionId', 'requestId'], 'Client Runtime cancellation')
|
||||
if (value.v !== INSPECTOR_PROTOCOL_VERSION || value.t !== 'client-runtime/cancel') {
|
||||
throw new Error('inspector protocol: invalid Client Runtime cancellation envelope')
|
||||
}
|
||||
return {
|
||||
v: INSPECTOR_PROTOCOL_VERSION,
|
||||
t: 'client-runtime/cancel',
|
||||
sourceId: wireId<'InspectorSourceId'>(value.sourceId, 'sourceId'),
|
||||
generation: wireId<'InspectorSourceGeneration'>(value.generation, 'generation'),
|
||||
sessionId: wireId<'ClientRuntimeSessionId'>(value.sessionId, 'sessionId'),
|
||||
requestId: wireId<'ClientRuntimeRequestId'>(value.requestId, 'requestId'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and rebuild one Worker acknowledgement for a Client Runtime response.
|
||||
* @param value - Untrusted acknowledgement frame.
|
||||
* @returns The validated acknowledgement frame.
|
||||
*/
|
||||
/* jscpd:ignore-start */
|
||||
// Deliberately mirrors parseClientRuntimeCancelFrame: each wire parser spells
|
||||
// out its own envelope literally instead of sharing a tag-parameterized helper.
|
||||
export function parseClientRuntimeResponseAcknowledgedFrame(
|
||||
value: Record<string, unknown>,
|
||||
): ClientRuntimeResponseAcknowledgedFrame {
|
||||
exactKeys(value, ['v', 't', 'sourceId', 'generation', 'sessionId', 'requestId'], 'Client Runtime response acknowledgement')
|
||||
if (value.v !== INSPECTOR_PROTOCOL_VERSION || value.t !== 'client-runtime/response-acknowledged') {
|
||||
throw new Error('inspector protocol: invalid Client Runtime response acknowledgement envelope')
|
||||
}
|
||||
return {
|
||||
v: INSPECTOR_PROTOCOL_VERSION,
|
||||
t: 'client-runtime/response-acknowledged',
|
||||
sourceId: wireId<'InspectorSourceId'>(value.sourceId, 'sourceId'),
|
||||
generation: wireId<'InspectorSourceGeneration'>(value.generation, 'generation'),
|
||||
sessionId: wireId<'ClientRuntimeSessionId'>(value.sessionId, 'sessionId'),
|
||||
requestId: wireId<'ClientRuntimeRequestId'>(value.requestId, 'requestId'),
|
||||
}
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/**
|
||||
* Parse and rebuild one Client-to-Worker Runtime response.
|
||||
* @param value - Untrusted response frame.
|
||||
* @returns The validated response frame.
|
||||
*/
|
||||
export function parseClientRuntimeResponseFrame(value: Record<string, unknown>): ClientRuntimeResponseFrame {
|
||||
exactKeys(value, ['v', 't', 'sourceId', 'generation', 'sessionId', 'requestId', 'outcome'], 'Client Runtime response')
|
||||
if (value.v !== INSPECTOR_PROTOCOL_VERSION || value.t !== 'client-runtime/response') {
|
||||
throw new Error('inspector protocol: invalid Client Runtime response envelope')
|
||||
}
|
||||
return {
|
||||
v: INSPECTOR_PROTOCOL_VERSION,
|
||||
t: 'client-runtime/response',
|
||||
sourceId: wireId<'InspectorSourceId'>(value.sourceId, 'sourceId'),
|
||||
generation: wireId<'InspectorSourceGeneration'>(value.generation, 'generation'),
|
||||
sessionId: wireId<'ClientRuntimeSessionId'>(value.sessionId, 'sessionId'),
|
||||
requestId: wireId<'ClientRuntimeRequestId'>(value.requestId, 'requestId'),
|
||||
outcome: parseOutcome(value.outcome),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and rebuild one Runtime-session cleanup notification.
|
||||
* @param value - Untrusted cleanup frame.
|
||||
* @returns The validated cleanup frame.
|
||||
*/
|
||||
export function parseClientRuntimeSessionClosedFrame(value: Record<string, unknown>): ClientRuntimeSessionClosedFrame {
|
||||
exactKeys(value, ['v', 't', 'sourceId', 'generation', 'sessionId'], 'Client Runtime session close')
|
||||
if (value.v !== INSPECTOR_PROTOCOL_VERSION || value.t !== 'client-runtime/session-closed') {
|
||||
throw new Error('inspector protocol: invalid Client Runtime session close envelope')
|
||||
}
|
||||
return {
|
||||
v: INSPECTOR_PROTOCOL_VERSION,
|
||||
t: 'client-runtime/session-closed',
|
||||
sourceId: wireId<'InspectorSourceId'>(value.sourceId, 'sourceId'),
|
||||
generation: wireId<'InspectorSourceGeneration'>(value.generation, 'generation'),
|
||||
sessionId: wireId<'ClientRuntimeSessionId'>(value.sessionId, 'sessionId'),
|
||||
}
|
||||
}
|
||||
|
||||
function parseOutcome(value: unknown): ClientRuntimeResponseFrame['outcome'] {
|
||||
if (!isPlainObject(value) || typeof value.ok !== 'boolean') {
|
||||
throw new Error('inspector protocol: invalid Client Runtime outcome')
|
||||
}
|
||||
if (value.ok) {
|
||||
exactKeys(value, ['ok', 'result'], 'successful Client Runtime outcome')
|
||||
return { ok: true, result: parseClientRuntimeResult(value.result) }
|
||||
}
|
||||
exactKeys(value, ['ok', 'error'], 'failed Client Runtime outcome')
|
||||
const error = exactObject(value.error, ['code', 'message'], 'Client Runtime error')
|
||||
if (!ERROR_CODES.has(error.code as ClientRuntimeError['code']) || typeof error.message !== 'string') {
|
||||
throw new Error('inspector protocol: invalid Client Runtime error')
|
||||
}
|
||||
return { ok: false, error: { code: error.code as ClientRuntimeError['code'], message: error.message } }
|
||||
}
|
||||
|
||||
const ERROR_CODES = new Set<ClientRuntimeError['code']>([
|
||||
'invalid-request', 'object-not-found', 'unsupported', 'timeout', 'result-too-large', 'internal-error',
|
||||
])
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Public types and boundary decoders for the Client Runtime wire protocol. */
|
||||
|
||||
export * from './commands.ts'
|
||||
export * from './console-frames.ts'
|
||||
export * from './frames.ts'
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user