@dimina-kit/devtools 0.4.0-dev.20260717120050 → 0.4.0-dev.20260718085557

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,61 +1,13 @@
1
- /**
2
- * Elements-panel forwarding — reflect the ACTIVE RENDER GUEST's live DOM tree in
3
- * the right-panel Chrome DevTools front-end, which natively inspects the
4
- * service-host (logic layer).
5
- *
6
- * ── Mechanism (no preload, no broker) ────────────────────────────────────────
7
- * The DevTools front-end is a `devtools://` page with NO dimina preload, so we
8
- * drive it through the same two-way poll bridge the network-forward path uses:
9
- * • front-end → main : we wrap `InspectorFrontendHost.sendMessageToBackend` in
10
- * the front-end realm. Commands whose method is in the RENDER domain set
11
- * (DOM/CSS/Overlay/DOMSnapshot/DOMDebugger) are NOT passed to the original
12
- * embedder channel — they're pushed onto `globalThis.__diminaElementsOutbound`
13
- * and main drains that array (splice) on a poll. EVERY OTHER method
14
- * (Runtime/Console/Page/Target/Emulation/…) calls through to the original, so
15
- * the service-host inspection (Console, Sources) and crucially the safe-area
16
- * `Emulation.setSafeAreaInsetsOverride` are untouched.
17
- * • main → front-end : we re-inject responses + render-side EVENTS via
18
- * `window.DevToolsAPI.dispatchMessage(json)` (chunked for large payloads —
19
- * `DOM.getDocument` can be big — mirroring network-forward/index.ts).
20
- *
21
- * ── Why no broker / no safe-area changes ─────────────────────────────────────
22
- * `webContents.debugger` is single-owner per wc, and the safe-area service has
23
- * already `attach('1.3')`-ed each render guest. We REUSE that already-attached
24
- * session (`sendCommand` + an extra `on('message')` listener on the SAME
25
- * `wc.debugger`); we never attach a second session and never detach one we don't
26
- * own. `DOM.enable`/`CSS.enable`/`Overlay.enable` do not reset the safe-area
27
- * `Emulation` override, so this is purely additive. ONLY when a guest's debugger
28
- * is NOT attached (safe-area degraded) do we `attach('1.3')` ourselves — tracked
29
- * in a `Set<wc.id>` so dispose/guest-destroy detaches ONLY the sessions we own;
30
- * safe-area's are never touched.
31
- *
32
- * ── Staleness (active-guest, NOT a generation counter) ───────────────────────
33
- * A response/event is honoured only while its originating guest is STILL the
34
- * active render guest — resolved fresh from the bridge per check (`isActiveWcId`),
35
- * not snapshotted. So a late response or stray render EVENT from a guest that is
36
- * no longer active is dropped (its in-flight command id is settled with an error
37
- * so the front-end never leaks a pending request, and stale nodes never bleed into
38
- * the new tree); and switching away and BACK to a previously-wired guest RESUMES
39
- * its forwarding (a snapshot would strand it).
40
- *
41
- * ── Degradation ──────────────────────────────────────────────────────────────
42
- * Hook unavailable / `DevToolsAPI` missing / no active guest → routing is simply
43
- * inert; the Elements panel falls back to the front-end's native service-host
44
- * DOM (the prior behaviour). Everything is try/catch + feature-detect + bounded
45
- * polling: it never throws, never blocks, never disturbs Console/Network/safe-area.
46
- *
47
- * This is a production feature (no env gate, default on for the native simulator).
48
- * It deliberately re-implements the small pure helpers it needs (routing table,
49
- * hook + dispatch scripts) as self-contained code.
50
- */
51
- import { webContents as electronWebContents } from 'electron';
52
1
  import { isFrontendSettled } from '../views/inject-when-ready.js';
2
+ import { VIRTUAL_REQUEST_ID_PREFIX } from '../network-forward/index.js';
3
+ import { PROBE_DEVTOOLS_API, MAX_SINGLE_DISPATCH_CHARS, CHUNK_CHARS, buildChunkedDispatchScript } from '../network-forward/frontend-dispatch.js';
4
+ import { createCdpSessionBroker } from '../cdp-session/index.js';
53
5
  /**
54
6
  * CDP domains that target the RENDER GUEST'S document tree. A command in one of
55
7
  * these is intercepted and sent to the active render guest's debugger instead of
56
8
  * the service host the front-end nominally inspects.
57
9
  */
58
- const RENDER_DOMAIN_PREFIXES = [
10
+ export const RENDER_DOMAIN_PREFIXES = [
59
11
  'DOM.',
60
12
  'CSS.',
61
13
  'Overlay.',
@@ -87,40 +39,89 @@ export function routeByDomain(method) {
87
39
  export function isRenderEventMethod(method) {
88
40
  return routeByDomain(method) === 'render';
89
41
  }
42
+ /**
43
+ * The Network commands answered from the network forwarder's prefetch cache
44
+ * when they target a virtual requestId. Only these two: they're the panel's
45
+ * body/post-data round-trips. Every other Network.* stays on the service path
46
+ * (a virtual id there errors exactly like an unknown id would — harmless).
47
+ */
48
+ export const NETWORK_BODY_METHODS = [
49
+ 'Network.getResponseBody',
50
+ 'Network.getRequestPostData',
51
+ ];
52
+ /**
53
+ * Full outbound routing decision — the SINGLE authority for where a front-end
54
+ * CDP command goes. `routeByDomain` covers the method-only render split; this
55
+ * adds the params-dependent network split. The hook script inlines an
56
+ * equivalent test driven by the same literals, so the two cannot drift.
57
+ *
58
+ * A non-string / missing requestId routes to 'service': only the
59
+ * `dimina:sim:` namespace is ours to answer, and the real backend is the
60
+ * correct authority for its own ids.
61
+ */
62
+ export function routeOutboundCommand(method, params) {
63
+ if (routeByDomain(method) === 'render')
64
+ return 'render';
65
+ if (NETWORK_BODY_METHODS.includes(method)) {
66
+ const requestId = params?.requestId;
67
+ if (typeof requestId === 'string' && requestId.startsWith(VIRTUAL_REQUEST_ID_PREFIX)) {
68
+ return 'network';
69
+ }
70
+ }
71
+ return 'service';
72
+ }
90
73
  // ── front-end injection (the front-end → main half of the bridge) ────────────
91
74
  /**
92
75
  * The JS injected into the DevTools front-end realm. Idempotent (sentinel guard).
93
- * Wraps `InspectorFrontendHost.sendMessageToBackend`: a RENDER-domain command is
94
- * pushed onto `globalThis.__diminaElementsOutbound` (drained by main) and NOT
95
- * forwarded to the original embedder channel (main answers it from the render
96
- * guest); every other command calls through to the original (service-host path).
76
+ * Wraps `InspectorFrontendHost.sendMessageToBackend` the ONE outbound CDP
77
+ * gate for every command the front-end emits. A command that routes 'render'
78
+ * or 'network' (same decision table as {@link routeOutboundCommand}) is pushed
79
+ * onto `globalThis.__diminaElementsOutbound` tagged with its route and NOT
80
+ * forwarded to the original embedder channel (main answers it — from the
81
+ * render guest or the network prefetch cache respectively); every other
82
+ * command calls through to the original (service-host path).
97
83
  *
98
84
  * Returns `'installed'` / `'already'` on success, `'partial'` while the embedder
99
85
  * global is not yet present (so the caller retries), `'error:…'` on a real fault.
100
86
  */
101
87
  export function buildElementsHookScript() {
102
88
  const prefixes = JSON.stringify(RENDER_DOMAIN_PREFIXES);
89
+ const netMethods = JSON.stringify(NETWORK_BODY_METHODS);
90
+ const vprefix = JSON.stringify(VIRTUAL_REQUEST_ID_PREFIX);
103
91
  return `(function(){try{
104
92
  if (globalThis.__diminaElementsHookInstalled) return 'already';
105
93
  var OUT = (globalThis.__diminaElementsOutbound = globalThis.__diminaElementsOutbound || []);
106
94
  var PREFIXES = ${prefixes};
95
+ var NET_METHODS = ${netMethods};
96
+ var VPREFIX = ${vprefix};
107
97
  function isRender(method){
108
98
  if (!method) return false;
109
99
  for (var i=0;i<PREFIXES.length;i++){ if (method.indexOf(PREFIXES[i])===0) return true; }
110
100
  return false;
111
101
  }
102
+ function routeOf(m){
103
+ if (!m || !m.method) return 'service';
104
+ if (isRender(m.method)) return 'render';
105
+ if (NET_METHODS.indexOf(m.method) >= 0 && m.params
106
+ && typeof m.params.requestId === 'string'
107
+ && m.params.requestId.indexOf(VPREFIX) === 0) return 'network';
108
+ return 'service';
109
+ }
112
110
  var IFH = globalThis.InspectorFrontendHost;
113
111
  if (IFH && typeof IFH.sendMessageToBackend === 'function' && !IFH.__diminaElementsWrapped){
114
112
  var origSend = IFH.sendMessageToBackend.bind(IFH);
115
113
  IFH.sendMessageToBackend = function(message){
116
114
  try {
117
115
  var m = (typeof message === 'string') ? JSON.parse(message) : message;
118
- if (m && isRender(m.method)){
119
- // Intercept: hand it to main to route at the render guest. Do NOT
120
- // call the original embedder channel (would hit the service host).
116
+ var route = routeOf(m);
117
+ if (route !== 'service'){
118
+ // Intercept: hand it to main to answer (render guest / network
119
+ // cache). Do NOT call the original embedder channel — the service
120
+ // host either has the wrong tree or has never heard of the id.
121
121
  OUT.push({ id: (m && typeof m.id === 'number') ? m.id : null,
122
122
  method: m.method, params: m.params || {},
123
- sessionId: (m && m.sessionId) ? m.sessionId : null });
123
+ sessionId: (m && m.sessionId) ? m.sessionId : null,
124
+ route: route });
124
125
  return;
125
126
  }
126
127
  } catch(_){ /* fall through to original on any parse hiccup */ }
@@ -151,13 +152,7 @@ function buildReconcileScript() {
151
152
  + `return { status: status, batch: batch };`
152
153
  + `})()`;
153
154
  }
154
- // ── main → front-end dispatch (mirrored from network-forward/index.ts) ────────
155
- // network-forward keeps these file-private and the task forbids touching it, so
156
- // they're re-implemented here. Keep the chunk contract in sync if that changes.
157
- const PROBE_DEVTOOLS_API = `(window.DevToolsAPI && typeof window.DevToolsAPI.dispatchMessage === 'function')`;
158
- /** A single dispatch payload may not exceed this many UTF-16 chars; chunk above. */
159
- const MAX_SINGLE_DISPATCH_CHARS = 1_000_000;
160
- const CHUNK_CHARS = 256 * 1024;
155
+ // ── main → front-end dispatch (shared transport with network-forward) ───────
161
156
  /** Dispatch ONE small CDP message (response or event) into the front-end. */
162
157
  function buildDispatchScript(message) {
163
158
  return `(()=>{try{`
@@ -166,25 +161,6 @@ function buildDispatchScript(message) {
166
161
  + `return true;`
167
162
  + `}catch(_){return false}})()`;
168
163
  }
169
- /**
170
- * Dispatch one LARGE message via `dispatchMessageChunk`: the FIRST chunk carries
171
- * the total size, every SUBSEQUENT chunk omits the second arg (Chromium's
172
- * continuation contract). Returns false when the chunk API isn't present yet.
173
- */
174
- function buildChunkedDispatchScript(chunks, totalSize) {
175
- const arr = JSON.stringify(chunks);
176
- return `(()=>{try{`
177
- + `if(!(window.DevToolsAPI&&typeof window.DevToolsAPI.dispatchMessageChunk==='function'))return false;`
178
- + `const cs=JSON.parse(${JSON.stringify(arr)});`
179
- + `for(let i=0;i<cs.length;i++){`
180
- + `try{`
181
- + `if(i===0){window.DevToolsAPI.dispatchMessageChunk(cs[i], ${totalSize})}`
182
- + `else{window.DevToolsAPI.dispatchMessageChunk(cs[i])}`
183
- + `}catch(_){}`
184
- + `}`
185
- + `return true;`
186
- + `}catch(_){return false}})()`;
187
- }
188
164
  /** Script that nudges the front-end to discard its document and lazily re-pull. */
189
165
  function buildDocumentUpdatedScript() {
190
166
  const msg = JSON.stringify({ method: 'DOM.documentUpdated', params: {} });
@@ -195,9 +171,13 @@ function buildDocumentUpdatedScript() {
195
171
  // within a tick, cheap because the install script short-circuits to 'already'.
196
172
  const DRAIN_INTERVAL_MS = 150;
197
173
  /**
198
- * Install Elements forwarding on a DevTools front-end host wc. Returns a disposer
199
- * (`stop`) that clears timers, unsubscribes render events, detaches ONLY the
200
- * debugger sessions this feature attached, and removes every listener it added.
174
+ * Install Elements forwarding on a DevTools front-end host wc. Returns a
175
+ * disposer (`stop`) that clears timers, unsubscribes render events, releases
176
+ * every broker lease this call acquired, and only when no `deps.broker` was
177
+ * supplied (this call owns its private broker) — disposes that broker too
178
+ * (which detaches ONLY the sessions it self-attached). When `deps.broker` IS
179
+ * supplied (the shared, app-wide instance), `stop()` never disposes it: other
180
+ * consumers may still be using it.
201
181
  *
202
182
  * Best-effort + defensive throughout: a destroyed wc ends the feature; every
203
183
  * executeJavaScript / sendCommand is wrapped so a torn-down guest never throws
@@ -206,6 +186,10 @@ const DRAIN_INTERVAL_MS = 150;
206
186
  */
207
187
  export function installElementsForward(deps) {
208
188
  const { devtoolsWc, bridge } = deps;
189
+ // Own (and tear down on stop()) a private broker only when the caller didn't
190
+ // supply a shared one — see ElementsForwardDeps.broker.
191
+ const ownsBroker = !deps.broker;
192
+ const broker = deps.broker ?? createCdpSessionBroker({ connections: deps.connections });
209
193
  let disposed = false;
210
194
  // A single self-healing loop drives BOTH hook (re)install and outbound drain;
211
195
  // it never self-terminates so a front-end reload (respawn) is re-hooked.
@@ -227,26 +211,26 @@ export function installElementsForward(deps) {
227
211
  const a = activeRenderWc();
228
212
  return a != null && a.id === id;
229
213
  };
230
- // Render guests we've added our `on('message')` listener to (keyed wc.id
231
- // cleanup) so a re-resolve of the same guest doesn't double-subscribe.
232
- const wiredGuests = new Map();
233
- // Debugger sessions THIS feature attached (safe-area was degraded). dispose /
234
- // guest-destroy detaches ONLY these; sessions safe-area owns are never touched.
235
- const selfAttached = new Set();
214
+ // Render guests we've acquired a broker lease for (keyed wc.id) so a
215
+ // re-resolve of the same guest doesn't double-subscribe.
216
+ const guestLeases = new Map();
236
217
  const stop = () => {
237
218
  disposed = true;
238
219
  if (reconcileTimer) {
239
220
  clearInterval(reconcileTimer);
240
221
  reconcileTimer = null;
241
222
  }
242
- for (const cleanup of wiredGuests.values()) {
223
+ for (const lease of guestLeases.values()) {
243
224
  try {
244
- cleanup();
225
+ lease.dispose();
245
226
  }
246
- catch { /* guest gone */ }
227
+ catch { /* already gone */ }
247
228
  }
248
- wiredGuests.clear();
249
- detachSelfAttached();
229
+ guestLeases.clear();
230
+ // Only detach sessions we self-attached if we own the broker's lifecycle —
231
+ // a shared/injected broker keeps serving other consumers past our stop().
232
+ if (ownsBroker)
233
+ broker.dispose();
250
234
  // Run the late-wired teardown once (render events, dom-ready, 'closed' sub).
251
235
  if (lateCleanup) {
252
236
  const c = lateCleanup;
@@ -257,20 +241,6 @@ export function installElementsForward(deps) {
257
241
  catch { /* already gone */ }
258
242
  }
259
243
  };
260
- /** Detach every session we own; leave safe-area's alone. */
261
- function detachSelfAttached() {
262
- for (const wcId of selfAttached) {
263
- const wc = wcFromId(wcId);
264
- if (!wc)
265
- continue;
266
- try {
267
- if (!wc.isDestroyed() && wc.debugger.isAttached())
268
- wc.debugger.detach();
269
- }
270
- catch { /* already detached / destroyed */ }
271
- }
272
- selfAttached.clear();
273
- }
274
244
  // ── main → front-end dispatch ──────────────────────────────────────────────
275
245
  function dispatchToFrontend(message) {
276
246
  if (disposed || devtoolsWc.isDestroyed())
@@ -345,16 +315,7 @@ export function installElementsForward(deps) {
345
315
  msg.sessionId = cmd.sessionId;
346
316
  dispatchToFrontend(msg);
347
317
  }
348
- // ── webContents lookup (id wc), tolerant of fakes/teardown ────────────────
349
- function wcFromId(id) {
350
- try {
351
- return electronWebContents?.fromId?.(id) ?? null;
352
- }
353
- catch {
354
- return null;
355
- }
356
- }
357
- // ── render-guest CDP session (reuse safe-area's; self-attach only if needed) ─
318
+ // ── render-guest CDP session (via the shared broker) ────────────────────────
358
319
  /** The active render guest, re-resolved fresh (pool/page swaps go stale). */
359
320
  function activeRenderWc() {
360
321
  try {
@@ -366,107 +327,42 @@ export function installElementsForward(deps) {
366
327
  }
367
328
  }
368
329
  /**
369
- * Ensure the guest's debugger is usable. Reuse the already-attached session if
370
- * safe-area attached it; only `attach('1.3')` ourselves when nobody has — and
371
- * record that wc in `selfAttached` so we (and only we) detach it later. Returns
372
- * false when the debugger can't be made usable.
373
- */
374
- function ensureGuestDebugger(wc) {
375
- try {
376
- if (wc.debugger.isAttached())
377
- return true;
378
- }
379
- catch {
380
- return false;
381
- }
382
- try {
383
- wc.debugger.attach('1.3');
384
- selfAttached.add(wc.id);
385
- return true;
386
- }
387
- catch {
388
- // Either someone else just attached (race → it IS usable) or attach truly
389
- // failed. Treat "already attached" as success, anything else as failure.
390
- try {
391
- return wc.debugger.isAttached();
392
- }
393
- catch {
394
- return false;
395
- }
396
- }
397
- }
398
- /**
399
- * Add our render-event re-injection listener to a guest's debugger ONCE. DOM./
400
- * CSS./Overlay./DOMSnapshot./DOMDebugger. EVENTS (no id) from the CURRENT
401
- * generation are dispatched into the front-end so the Elements panel updates
402
- * live; events arriving after the generation moved on are dropped. The listener
403
- * is removed when the guest wc is destroyed (and on stop()).
330
+ * Get-or-create this guest's broker lease and wire our render-event
331
+ * re-injection listener on it ONCE. DOM./CSS./Overlay./DOMSnapshot./
332
+ * DOMDebugger. EVENTS (no id) from the CURRENT generation are dispatched
333
+ * into the front-end so the Elements panel updates live; events arriving
334
+ * after the generation moved on are dropped. `lease.onDetach` — which fires
335
+ * on an external detach OR the guest being destroyed (see cdp-session's
336
+ * design doc) — drops our cache entry so the NEXT call re-acquires fresh
337
+ * instead of operating on a dead lease forever.
404
338
  */
405
- function wireGuestEvents(wc) {
406
- if (wiredGuests.has(wc.id))
407
- return;
408
- const onMessage = (_event, method, params) => {
339
+ function ensureGuestLease(wc) {
340
+ const existing = guestLeases.get(wc.id);
341
+ if (existing)
342
+ return existing;
343
+ const lease = broker.acquire(wc);
344
+ if (!lease)
345
+ return null;
346
+ lease.onMessage((method, params) => {
409
347
  if (disposed)
410
348
  return;
411
- // Honour events only while this guest is STILL the active one — checked per
412
- // event, so re-activating a previously-wired guest resumes forwarding.
349
+ // Honour events only while this guest is STILL the active one — checked
350
+ // per event, so re-activating a previously-wired guest resumes forwarding.
413
351
  if (!isActiveWcId(wc.id))
414
352
  return;
415
353
  if (isRenderEventMethod(method)) {
416
354
  dispatchToFrontend({ method, params });
417
355
  }
418
- };
419
- try {
420
- wc.debugger.on('message', onMessage);
421
- }
422
- catch {
423
- return;
424
- }
425
- const onDestroyed = () => {
426
- const cleanup = wiredGuests.get(wc.id);
427
- if (cleanup) {
428
- wiredGuests.delete(wc.id);
429
- try {
430
- cleanup();
431
- }
432
- catch { /* gone */ }
433
- }
434
- // A destroyed guest is no longer the active one, so its in-flight commands
435
- // fail `isActiveWcId` and settle as errors on their own — no global bump
436
- // (which would wrongly stale OTHER, still-active guests' commands).
437
- // If we own this wc's session there is nothing left to detach; drop it.
438
- selfAttached.delete(wc.id);
439
- };
440
- let closedSub;
441
- try {
442
- // Route the guest teardown through the connection registry's `'closed'`
443
- // event when present. Render guests are NEVER pool-reset, so `'closed'`
444
- // (fires only on real wc destroy) is the correct lifetime — and crucially
445
- // `on('closed')` returns a handle whose `dispose()` REMOVES the listener
446
- // WITHOUT firing it, matching the original `removeListener` semantics.
447
- // (`own()` would fire `onDestroyed` on release AND mutate `wiredGuests`
448
- // mid-drain in stop() — wrong here; see foundation.md §4.3.) Same
449
- // try/catch guards a fake/minimal wc that lacks once/emitter wiring.
450
- if (deps.connections)
451
- closedSub = deps.connections.acquire(wc).on('closed', onDestroyed);
452
- else
453
- wc.once('destroyed', onDestroyed);
454
- }
455
- catch { /* fake/minimal wc */ }
456
- wiredGuests.set(wc.id, () => {
457
- try {
458
- wc.debugger.removeListener('message', onMessage);
459
- }
460
- catch { /* gone */ }
461
- try {
462
- closedSub?.dispose();
463
- }
464
- catch { /* gone */ }
465
- try {
466
- wc.removeListener('destroyed', onDestroyed);
467
- }
468
- catch { /* gone */ }
469
356
  });
357
+ lease.onDetach(() => {
358
+ // A destroyed/detached guest is no longer the active one, so its
359
+ // in-flight commands fail `isActiveWcId` and settle as errors on their
360
+ // own — no global bump (which would wrongly stale OTHER, still-active
361
+ // guests' commands).
362
+ guestLeases.delete(wc.id);
363
+ });
364
+ guestLeases.set(wc.id, lease);
365
+ return lease;
470
366
  }
471
367
  /**
472
368
  * enable DOM/CSS/Overlay + wire events on a guest. Best-effort (never throws).
@@ -478,18 +374,24 @@ export function installElementsForward(deps) {
478
374
  * the Elements-panel hover highlight never paints. Awaiting DOM.enable first
479
375
  * guarantees the correct enable order. CSS.enable has no such dependency and is
480
376
  * fire-and-forget alongside.
377
+ *
378
+ * This does NOT delegate to `lease.ensureRenderDomains()`: that helper has no
379
+ * concept of "active guest", but a priming sequence started on a guest that
380
+ * stops being active mid-flight must NOT arm Overlay on the now-abandoned
381
+ * tree (checked again below) — a per-consumer concern only elements-forward
382
+ * has, so it keeps its own sequence, just dispatched through the lease.
481
383
  */
482
384
  function primeGuest(wc) {
483
- if (!ensureGuestDebugger(wc))
385
+ const lease = ensureGuestLease(wc);
386
+ if (!lease)
484
387
  return;
485
- wireGuestEvents(wc);
486
- void enableGuestDomains(wc);
388
+ void enableGuestDomains(wc, lease);
487
389
  }
488
390
  /** Enable the render domains in dependency order. Resolves; never rejects. */
489
- async function enableGuestDomains(wc) {
490
- wc.debugger.sendCommand('CSS.enable').catch(() => { });
391
+ async function enableGuestDomains(wc, lease) {
392
+ lease.send('CSS.enable').catch(() => { });
491
393
  try {
492
- await wc.debugger.sendCommand('DOM.enable');
394
+ await lease.send('DOM.enable');
493
395
  }
494
396
  catch {
495
397
  // Guest mid-destroy or DOM domain unavailable; Overlay.enable would only
@@ -501,7 +403,7 @@ export function installElementsForward(deps) {
501
403
  // arming Overlay so a stale/dead guest is left untouched.
502
404
  if (disposed || !isActiveWcId(wc.id))
503
405
  return;
504
- wc.debugger.sendCommand('Overlay.enable').catch(() => { });
406
+ lease.send('Overlay.enable').catch(() => { });
505
407
  }
506
408
  // ── front-end → render command routing ─────────────────────────────────────
507
409
  function routeCommand(cmd) {
@@ -510,18 +412,18 @@ export function installElementsForward(deps) {
510
412
  replyError(cmd, 'no active render guest');
511
413
  return;
512
414
  }
513
- if (!ensureGuestDebugger(wc)) {
415
+ const lease = ensureGuestLease(wc);
416
+ if (!lease) {
514
417
  replyError(cmd, 'render guest debugger unavailable');
515
418
  return;
516
419
  }
517
- wireGuestEvents(wc);
518
420
  // The wc this command is dispatched to. A response that resolves after the
519
421
  // active guest changed (this wc is no longer active) is for a tree the
520
422
  // front-end has abandoned: settle the id with an error (no pending leak) but
521
423
  // do NOT feed its result into the new tree.
522
424
  const cmdWcId = wc.id;
523
- wc.debugger
524
- .sendCommand(cmd.method, (cmd.params ?? {}))
425
+ lease
426
+ .send(cmd.method, (cmd.params ?? {}))
525
427
  .then((result) => {
526
428
  if (disposed)
527
429
  return;
@@ -535,7 +437,7 @@ export function installElementsForward(deps) {
535
437
  // enabled" and the hover highlight stops painting. Re-arm it so the next
536
438
  // hover paints — only while this guest is still the active one.
537
439
  if (cmd.method === 'Overlay.disable' && isActiveWcId(cmdWcId)) {
538
- wc.debugger.sendCommand('Overlay.enable').catch(() => { });
440
+ lease.send('Overlay.enable').catch(() => { });
539
441
  }
540
442
  replyResult(cmd, result);
541
443
  })
@@ -545,7 +447,32 @@ export function installElementsForward(deps) {
545
447
  replyError(cmd, err instanceof Error ? err.message : String(err));
546
448
  });
547
449
  }
548
- /** Process a drained batch of front-end commands. */
450
+ /**
451
+ * Answer a network-routed command from the provider. Never touches the render
452
+ * guest: the body lives in the network forwarder's prefetch cache (or
453
+ * nowhere). A missing provider or a rejected lookup settles the command with
454
+ * the canonical CDP not-found error so the front-end renders its normal
455
+ * failure state instead of leaking a forever-pending request.
456
+ */
457
+ function answerNetworkCommand(cmd) {
458
+ const provider = deps.network;
459
+ const requestId = cmd.params?.requestId;
460
+ if (!provider || typeof requestId !== 'string') {
461
+ replyError(cmd, 'No resource with given identifier found');
462
+ return;
463
+ }
464
+ const lookup = cmd.method === 'Network.getRequestPostData'
465
+ ? provider.getRequestPostData(requestId)
466
+ : provider.getResponseBody(requestId);
467
+ lookup.then((result) => {
468
+ if (!disposed)
469
+ replyResult(cmd, result);
470
+ }, (err) => {
471
+ if (!disposed)
472
+ replyError(cmd, err instanceof Error ? err.message : String(err));
473
+ });
474
+ }
475
+ /** Process a drained batch of front-end commands, splitting by route tag. */
549
476
  function handleOutbound(batch) {
550
477
  if (!Array.isArray(batch))
551
478
  return;
@@ -555,6 +482,12 @@ export function installElementsForward(deps) {
555
482
  const cmd = raw;
556
483
  if (typeof cmd.method !== 'string')
557
484
  continue;
485
+ if (cmd.route === 'network') {
486
+ answerNetworkCommand(cmd);
487
+ continue;
488
+ }
489
+ // 'render' — and any untagged legacy payload, which only ever carried
490
+ // render-domain commands.
558
491
  routeCommand(cmd);
559
492
  }
560
493
  }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * PrefetchCache — a bounded, TTL'd "prime now / read later" promise cache.
3
+ *
4
+ * The network forwarder prefetches `Network.getResponseBody` /
5
+ * `Network.getRequestPostData` from the simulator's CDP session the moment a
6
+ * request finishes (the renderer may evict the resource soon after), keyed by
7
+ * the namespaced virtual requestId. A later Response-tab click resolves from
8
+ * here instead of re-issuing a debugger round-trip against a session that may
9
+ * have moved on.
10
+ *
11
+ * Contract:
12
+ * - `prime(id, fetch)` is idempotent per id: while an entry is pending or a
13
+ * settled entry is still live, later primes are ignored.
14
+ * - A fetch rejection — or a resolved value whose `sizeOf` exceeds
15
+ * `perEntryMaxChars` — becomes an ERROR SENTINEL: the id stays known but
16
+ * every lookup rejects with the canonical not-found message (the same
17
+ * answer a real CDP backend gives for an unknown requestId, so the
18
+ * front-end renders its normal failure state).
19
+ * - `lookup(id)` resolves the cached value, awaits a still-pending fetch (a
20
+ * panel click can land before the prefetch settles), and rejects
21
+ * not-found for unknown / expired / evicted / sentinel entries.
22
+ * - Bounds apply to SETTLED entries only: LRU by settle/lookup recency up to
23
+ * `maxEntries`, plus a `maxTotalChars` budget over `sizeOf`. A pending
24
+ * entry counts 0 and is never evicted — evicting an in-flight prefetch
25
+ * would strand its waiters.
26
+ * - TTL is anchored to the SETTLE time (not the prime() call time) and is
27
+ * enforced lazily on the next prime/lookup.
28
+ */
29
+ /**
30
+ * Default per-entry size ceiling (chars). Exported so callers that need to
31
+ * pre-screen a fetch BEFORE issuing it (e.g. skipping a CDP round-trip for a
32
+ * response already known to be oversized) reference the exact same number
33
+ * `PrefetchCache`'s own default enforces — one source of truth, not two
34
+ * independently-configured limits that could silently drift apart.
35
+ */
36
+ export declare const DEFAULT_PER_ENTRY_MAX_CHARS: number;
37
+ export interface PrefetchCacheOptions {
38
+ maxEntries?: number;
39
+ maxTotalChars?: number;
40
+ perEntryMaxChars?: number;
41
+ ttlMs?: number;
42
+ /** Injectable clock for TTL tests. */
43
+ now?: () => number;
44
+ }
45
+ export declare class PrefetchCache<V> {
46
+ private readonly sizeOf;
47
+ private readonly map;
48
+ private readonly maxEntries;
49
+ private readonly maxTotalChars;
50
+ private readonly perEntryMaxChars;
51
+ private readonly ttlMs;
52
+ private readonly now;
53
+ constructor(sizeOf: (v: V) => number, opts?: PrefetchCacheOptions);
54
+ /**
55
+ * Returns `true` when this call actually started a new fetch, `false` when
56
+ * it was a no-op (idempotent re-prime of an id that's already pending or
57
+ * still live). Callers that admission-gate priming (a concurrency cap) need
58
+ * this to know whether they actually consumed a slot — incrementing a
59
+ * counter unconditionally would leak a slot on every no-op re-prime.
60
+ */
61
+ prime(id: string, fetch: () => Promise<V>): boolean;
62
+ lookup(id: string): Promise<V>;
63
+ clear(): void;
64
+ get size(): number;
65
+ private isExpired;
66
+ private sweepExpired;
67
+ /** Evict oldest SETTLED entries until both bounds hold; pending are exempt. */
68
+ private evict;
69
+ }
70
+ //# sourceMappingURL=body-cache.d.ts.map