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

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.
Files changed (29) hide show
  1. package/dist/main/app/app.js +3 -1
  2. package/dist/main/index.bundle.js +7611 -7304
  3. package/dist/main/services/cdp-session/index.d.ts +87 -0
  4. package/dist/main/services/cdp-session/index.js +173 -0
  5. package/dist/main/services/elements-forward/index.d.ts +132 -15
  6. package/dist/main/services/elements-forward/index.js +162 -229
  7. package/dist/main/services/network-forward/body-cache.d.ts +70 -0
  8. package/dist/main/services/network-forward/body-cache.js +175 -0
  9. package/dist/main/services/network-forward/frontend-dispatch.d.ts +27 -0
  10. package/dist/main/services/network-forward/frontend-dispatch.js +42 -0
  11. package/dist/main/services/network-forward/index.d.ts +80 -9
  12. package/dist/main/services/network-forward/index.js +419 -149
  13. package/dist/main/services/render-inspect/index.d.ts +10 -0
  14. package/dist/main/services/render-inspect/index.js +17 -98
  15. package/dist/main/services/safe-area/index.d.ts +4 -1
  16. package/dist/main/services/safe-area/index.js +62 -34
  17. package/dist/main/services/simulator-storage/index.d.ts +22 -4
  18. package/dist/main/services/simulator-storage/index.js +51 -64
  19. package/dist/main/services/views/native-simulator-devtools-host.js +12 -1
  20. package/dist/main/services/views/native-simulator-view.js +6 -0
  21. package/dist/main/services/views/view-manager.d.ts +9 -0
  22. package/dist/main/services/views/view-manager.js +1 -1
  23. package/dist/main/services/workbench-context.d.ts +9 -0
  24. package/dist/main/services/workbench-context.js +3 -0
  25. package/dist/native-host/common/common.js +154 -58
  26. package/dist/native-host/container/pageFrame.css +1 -1
  27. package/dist/native-host/render/render.js +6578 -3291
  28. package/dist/native-host/service/service.js +2 -2
  29. package/package.json +5 -5
@@ -1,6 +1,17 @@
1
- import { DisposableRegistry, toDisposable } from '@dimina-kit/electron-deck/main';
1
+ import { SyncDisposableRegistry, toDisposable } from '@dimina-kit/electron-deck/main';
2
2
  import { isFrontendSettled } from '../views/inject-when-ready.js';
3
3
  import { packDispatchBatch } from './dispatch-batch.js';
4
+ import { PrefetchCache, DEFAULT_PER_ENTRY_MAX_CHARS } from './body-cache.js';
5
+ import { PROBE_DEVTOOLS_API, MAX_SINGLE_DISPATCH_CHARS, CHUNK_CHARS, buildChunkedDispatchScript } from './frontend-dispatch.js';
6
+ import { createCdpSessionBroker } from '../cdp-session/index.js';
7
+ /**
8
+ * Namespace prefix of every virtual requestId this forwarder injects into the
9
+ * DevTools front-end. SINGLE SOURCE for the literal: the front-end outbound
10
+ * gate (elements-forward) keys its `Network.getResponseBody` /
11
+ * `Network.getRequestPostData` interception on this exact prefix, so the two
12
+ * modules can never drift apart on what counts as "one of ours".
13
+ */
14
+ export const VIRTUAL_REQUEST_ID_PREFIX = 'dimina:sim:';
4
15
  // ── requestId namespacing (pure, testable) ──────────────────────────────────
5
16
  /**
6
17
  * CDP events whose `params.requestId` we rewrite into the virtual namespace.
@@ -86,7 +97,7 @@ export class RequestIdNamespace {
86
97
  this.map.set(rawId, existing);
87
98
  return existing.virtual;
88
99
  }
89
- const virtual = `dimina:sim:${this.epoch}:${this.seq++}:${rawId}`;
100
+ const virtual = `${VIRTUAL_REQUEST_ID_PREFIX}${this.epoch}:${this.seq++}:${rawId}`;
90
101
  this.map.set(rawId, { virtual, expires: t + this.ttlMs, active: true });
91
102
  this.evict(t);
92
103
  return virtual;
@@ -151,8 +162,6 @@ export function rewriteRequestId(method, params, ns) {
151
162
  return { method, params: { ...p, requestId: virtual } };
152
163
  }
153
164
  // ── DevTools front-end injection (the primary sink) ─────────────────────────
154
- /** Probe the front-end realm exposes `DevToolsAPI.dispatchMessage`. */
155
- const PROBE_DEVTOOLS_API = `(window.DevToolsAPI && typeof window.DevToolsAPI.dispatchMessage === 'function')`;
156
165
  /**
157
166
  * Build the `executeJavaScript` source that dispatches a BATCH of raw CDP
158
167
  * messages into the DevTools front-end. Each message is carried as a JSON
@@ -169,37 +178,6 @@ function buildDispatchScript(messages) {
169
178
  + `return true;`
170
179
  + `}catch(_){return false}})()`;
171
180
  }
172
- /**
173
- * Build the `executeJavaScript` source for a chunked dispatch of one large CDP
174
- * message — DevTools' own transport caps a single `dispatchMessage` payload, so
175
- * the front-end exposes `dispatchMessageChunk(messageChunk, messageSize)` where
176
- * the FIRST chunk carries the total size and EVERY SUBSEQUENT chunk is called
177
- * with the chunk ONLY (second arg omitted). This matches Chromium's
178
- * `devtools_ui_bindings.cc` DispatchProtocolMessage, whose front-end treats a
179
- * call with a second argument as the start of a new message and a call without
180
- * one as a continuation. Passing 0 (the previous behaviour) risked the
181
- * front-end mis-reading a continuation as a fresh 0-size message. Returns false
182
- * when the API isn't available.
183
- */
184
- function buildChunkedDispatchScript(chunks, totalSize) {
185
- const arr = JSON.stringify(chunks);
186
- return `(()=>{try{`
187
- + `if(!(window.DevToolsAPI&&typeof window.DevToolsAPI.dispatchMessageChunk==='function'))return false;`
188
- + `const cs=JSON.parse(${JSON.stringify(arr)});`
189
- + `for(let i=0;i<cs.length;i++){`
190
- + `try{`
191
- // First chunk: (chunk, totalSize). Subsequent chunks: (chunk) — second arg
192
- // omitted, NOT 0, per Chromium's continuation contract.
193
- + `if(i===0){window.DevToolsAPI.dispatchMessageChunk(cs[i], ${totalSize})}`
194
- + `else{window.DevToolsAPI.dispatchMessageChunk(cs[i])}`
195
- + `}catch(_){}`
196
- + `}`
197
- + `return true;`
198
- + `}catch(_){return false}})()`;
199
- }
200
- /** A single dispatch payload may not exceed this many UTF-16 chars; chunk above. */
201
- const MAX_SINGLE_DISPATCH_CHARS = 1_000_000;
202
- const CHUNK_CHARS = 256 * 1024;
203
181
  /**
204
182
  * Upper bound on the COMBINED size (in UTF-16 chars) of the messages packed into
205
183
  * one `executeJavaScript` batch. 2000 small messages otherwise stitch into a
@@ -238,11 +216,88 @@ const DEVTOOLS_READY_TIMEOUT_MS = 5_000;
238
216
  /** Poll interval while probing for the front-end API to become ready. */
239
217
  const READY_RETRY_MS = 100;
240
218
  export function createNetworkForwarder(bridge) {
241
- const registry = new DisposableRegistry();
242
- // The simulator WCV we currently have a debugger session on, and the
243
- // per-attach teardown (debugger listeners + detach). Null when not attached.
219
+ const registry = new SyncDisposableRegistry();
220
+ let disposed = false;
221
+ // ── Response body / post-data prefetch (serves the front-end's clicks) ─────
222
+ // Keyed by VIRTUAL requestId and owned by the forwarder (not the attach):
223
+ // epochs make ids non-colliding, so entries stay servable across a simulator
224
+ // detach/re-attach while the panel still shows the old rows. Bounded + TTL'd
225
+ // in the cache itself.
226
+ const bodyCache = new PrefetchCache((v) => v.body.length);
227
+ const postDataCache = new PrefetchCache((v) => v.postData.length);
228
+ // ── Prefetch admission control ──────────────────────────────────────────────
229
+ // Every completed request (simulator + all render guests combined — this
230
+ // counter is forwarder-wide, not per-attach) would otherwise trigger an
231
+ // unconditional `Network.getResponseBody` CDP round-trip that must
232
+ // materialize the FULL body into main-process memory before the cache's own
233
+ // per-entry/total-size limits can reject it (those limits apply only to
234
+ // SETTLED entries — a pending fetch counts 0 and is eviction-exempt). A page
235
+ // that finishes many large resources at once (e.g. many concurrent images —
236
+ // exactly the render-guest capture path) could otherwise pile up unboundedly
237
+ // many simultaneous full-body reads. This caps how many prefetches (body +
238
+ // post-data combined) may be in flight at once; once at the cap, further
239
+ // completions are simply skipped — the panel's Response tab 404s for those
240
+ // (same as any other not-found id) rather than the process risking a memory
241
+ // spike. `primeWithAdmission`'s bookkeeping only counts a slot when the cache
242
+ // actually started a new fetch (PrefetchCache.prime()'s idempotent no-op
243
+ // return doesn't consume one).
244
+ const MAX_CONCURRENT_PREFETCHES = 32;
245
+ let pendingPrefetchCount = 0;
246
+ /**
247
+ * `fetch` MUST be an `async` function (both current call sites in
248
+ * `prefetchBodies` are). An `async` function can never throw synchronously —
249
+ * any exception in its body is spec-guaranteed to surface as a rejected
250
+ * promise instead — which is what guarantees `release()` always eventually
251
+ * runs via the `.then()` below. A plain (non-async) function that throws
252
+ * synchronously when CALLED would bypass `.then()` entirely and leak this
253
+ * slot forever (`PrefetchCache.prime` catches that synchronous throw and
254
+ * still reports `started: true`), so never pass one here.
255
+ */
256
+ function primeWithAdmission(cache, id, fetch) {
257
+ if (pendingPrefetchCount >= MAX_CONCURRENT_PREFETCHES)
258
+ return;
259
+ pendingPrefetchCount++;
260
+ let released = false;
261
+ const release = () => { if (!released) {
262
+ released = true;
263
+ pendingPrefetchCount--;
264
+ } };
265
+ const started = cache.prime(id, () => fetch().then((v) => { release(); return v; }, (err) => { release(); throw err; }));
266
+ if (!started)
267
+ release();
268
+ }
269
+ // The simulator WCV we currently have a debugger session on, our broker
270
+ // lease for it, and the per-attach teardown (message listener). Null when
271
+ // not attached.
244
272
  let simWc = null;
273
+ let simLease = null;
245
274
  let attachDisposables = null;
275
+ // Render-host guests wired for capture: wc.id → SYNCHRONOUS per-guest
276
+ // teardown (message listener + broker lease). Attach/detach ownership for
277
+ // these sessions lives entirely in the shared broker now (see cdp-session/
278
+ // index.ts) — this map only tracks OUR OWN wiring (the 'message' listener
279
+ // wireNetworkCapture installs), not who may detach the underlying session.
280
+ const guestWired = new Map();
281
+ // wc.id → the generation token of the most recently scheduled render-guest
282
+ // retry (acquire refused, or an established session got detached).
283
+ // `guestWired` alone cannot de-duplicate repeat `attachRenderGuest` calls
284
+ // during this window — it is only populated on a SUCCESSFUL acquire — so
285
+ // without this, calling `attachRenderGuest(wc)` again while the exclusive
286
+ // holder keeps refusing spawns a second, independent 300ms retry chain.
287
+ // A bare presence flag is NOT enough: a stale timer from an EARLIER retry
288
+ // cycle (superseded by an intervening successful reattach, itself followed
289
+ // by a fresh detach that scheduled its own new retry) would, on firing,
290
+ // unconditionally clear whatever is recorded — including that newer retry's
291
+ // own marker — and then reschedule itself, forking back into two parallel
292
+ // chains. A monotonic generation token lets a fired timer recognize its own
293
+ // staleness (its captured token no longer matches the current one) and
294
+ // no-op instead of touching state it no longer owns.
295
+ const guestRetryGeneration = new Map();
296
+ let nextRenderGuestRetryGeneration = 0;
297
+ // Own (and dispose on this forwarder's own dispose()) a private broker only
298
+ // when the caller didn't supply a shared one.
299
+ const ownsBroker = !bridge.broker;
300
+ const broker = bridge.broker ?? createCdpSessionBroker({ connections: bridge.connections });
246
301
  // The DevTools front-end host wc (primary sink), set by the ViewManager.
247
302
  let devtoolsWc = null;
248
303
  // Teardown for the wc 'destroyed' watcher on the current host (clears the host).
@@ -514,13 +569,30 @@ export function createNetworkForwarder(bridge) {
514
569
  }
515
570
  wc.executeJavaScript(script, true).catch(() => { });
516
571
  }
572
+ /**
573
+ * Stop USING the simulator wc's session. Releases our own lease (message
574
+ * listener + Network capture wiring) but never forces a physical
575
+ * `debugger.detach()` — this same simulator wc's debugger session is also
576
+ * independently used by simulator-storage (DOMStorage capture), and an
577
+ * unconditional detach here would kill ITS capture too, exactly like an
578
+ * unconditional detach on ITS side would kill ours. The broker (see
579
+ * cdp-session/index.ts) is the only thing that decides when an actual
580
+ * detach happens (wc destroy, or its own top-level dispose() for a
581
+ * self-attached session) — never a single consumer switching away.
582
+ */
517
583
  function detachSimulator() {
518
- const wc = simWc;
519
584
  simWc = null;
585
+ const lease = simLease;
586
+ simLease = null;
520
587
  const ad = attachDisposables;
521
588
  attachDisposables = null;
522
- if (ad)
523
- void ad.disposeAll().catch(() => { });
589
+ if (ad) {
590
+ try {
591
+ ad.disposeAll();
592
+ }
593
+ catch { /* best-effort teardown */ }
594
+ }
595
+ lease?.dispose();
524
596
  // Drop any queued-but-unflushed messages so a new attach starts clean, and
525
597
  // reset the native-sink state machine (the next attach re-probes the host).
526
598
  dispatchQueue = [];
@@ -537,38 +609,121 @@ export function createNetworkForwarder(bridge) {
537
609
  clearTimeout(readyTimeoutTimer);
538
610
  readyTimeoutTimer = null;
539
611
  }
540
- if (!wc || wc.isDestroyed())
541
- return;
542
- try {
543
- if (wc.debugger.isAttached())
544
- wc.debugger.detach();
545
- }
546
- catch { /* already detached / mid-destroy */ }
547
612
  }
548
- function attachSimulator(wc) {
549
- if (!wc || wc.isDestroyed())
550
- return;
551
- if (simWc === wc && !wc.isDestroyed())
552
- return;
553
- detachSimulator();
554
- try {
555
- if (!wc.debugger.isAttached()) {
556
- wc.debugger.attach('1.3');
557
- }
558
- }
559
- catch (err) {
560
- console.warn('[network-forward] debugger.attach failed; simulator network not captured:', err instanceof Error ? err.message : err);
561
- return;
562
- }
563
- simWc = wc;
564
- const attach = new DisposableRegistry();
565
- attachDisposables = attach;
566
- // Per-attach requestId namespace: a fresh epoch each attach guarantees ids
567
- // from a previous simulator session can never collide with this one.
568
- const ns = new RequestIdNamespace(String(Date.now()));
613
+ /**
614
+ * Wire one wc's already-usable debugger session for Network capture: a fresh
615
+ * per-attach requestId namespace (epochs keep ids from ever colliding across
616
+ * sources/attaches), the raw-event forward into the DevTools front-end, the
617
+ * loadingFinished-time body/post-data prefetch, and the console-fallback
618
+ * bookkeeping (tagged with `source`). Shared verbatim by the simulator
619
+ * attach (owner semantics) and every render-guest attach (shared-session
620
+ * semantics) — the capture pipeline is identical, only session ownership
621
+ * differs. The 'message' listener teardown is registered on `attach`.
622
+ */
623
+ function wireNetworkCapture(wc, source, attach, epoch) {
624
+ const ns = new RequestIdNamespace(epoch);
569
625
  // Fallback bookkeeping: requestId → in-flight request, for the console line
570
626
  // when the native dispatch path is unusable.
571
627
  const pending = new Map();
628
+ // Raw ids whose requestWillBeSent announced a post body that was NOT
629
+ // inlined (`hasPostData` without `postData`) — the only case the front-end
630
+ // round-trips `Network.getRequestPostData`. Consumed at loadingFinished.
631
+ const postDataWanted = new Set();
632
+ /**
633
+ * Prefetch the response body (and, when flagged, the post data) from this
634
+ * wc's CDP session the moment the request finishes — the renderer may
635
+ * evict the resource soon after, so a click-time fetch would race it.
636
+ * Gated exactly like event forwarding: with no devtools host to serve
637
+ * (idle-without-host / degraded) there is no panel to click, so buffering
638
+ * bodies would only burn memory.
639
+ */
640
+ const prefetchBodies = (rawId, encodedDataLength) => {
641
+ if (sink === 'degraded')
642
+ return;
643
+ if (sink === 'idle' && !resolveDevtoolsWc())
644
+ return;
645
+ const virtualId = ns.resolve(rawId);
646
+ // Skip the CDP round-trip entirely for a response already known (from
647
+ // the wire size CDP reports at completion) to exceed the cache's own
648
+ // per-entry ceiling — no point materializing a full body into main-
649
+ // process memory just to have the cache reject it after the fact. This
650
+ // is a best-effort heuristic (encodedDataLength is the ON-THE-WIRE size;
651
+ // a decompressed body can be larger), not a hard guarantee — the
652
+ // concurrency cap below is what actually bounds worst-case memory.
653
+ const knownOversized = typeof encodedDataLength === 'number' && encodedDataLength > DEFAULT_PER_ENTRY_MAX_CHARS;
654
+ if (!knownOversized) {
655
+ primeWithAdmission(bodyCache, virtualId, async () => {
656
+ const raw = await wc.debugger.sendCommand('Network.getResponseBody', { requestId: rawId });
657
+ const r = raw;
658
+ if (!r || typeof r.body !== 'string')
659
+ throw new Error('response body unavailable');
660
+ return { body: r.body, base64Encoded: r.base64Encoded === true };
661
+ });
662
+ }
663
+ if (!postDataWanted.delete(rawId))
664
+ return;
665
+ primeWithAdmission(postDataCache, virtualId, async () => {
666
+ const raw = await wc.debugger.sendCommand('Network.getRequestPostData', { requestId: rawId });
667
+ const r = raw;
668
+ if (!r || typeof r.postData !== 'string')
669
+ throw new Error('post data unavailable');
670
+ return { postData: r.postData };
671
+ });
672
+ };
673
+ // ── Fallback bookkeeping handlers — one per Network.* method, each kept
674
+ // simple so `onMessage` itself stays a flat dispatch (not a branchy switch). ──
675
+ function onRequestWillBeSent(params) {
676
+ const p = params;
677
+ if (!p?.request)
678
+ return;
679
+ if (pending.size >= MAX_PENDING)
680
+ pending.clear();
681
+ pending.set(p.requestId, { url: p.request.url, method: p.request.method, status: 0 });
682
+ // A body announced but not inlined is the one case the panel will
683
+ // round-trip `Network.getRequestPostData` — flag it for prefetch.
684
+ if (p.request.hasPostData === true && typeof p.request.postData !== 'string') {
685
+ if (postDataWanted.size >= MAX_PENDING)
686
+ postDataWanted.clear();
687
+ postDataWanted.add(p.requestId);
688
+ }
689
+ }
690
+ function onResponseReceived(params) {
691
+ const p = params;
692
+ const req = pending.get(p.requestId);
693
+ if (req)
694
+ req.status = p.response?.status ?? 0;
695
+ }
696
+ function onLoadingFinished(params) {
697
+ const p = params;
698
+ if (typeof p?.requestId === 'string')
699
+ prefetchBodies(p.requestId, p.encodedDataLength);
700
+ const req = pending.get(p.requestId);
701
+ if (!req)
702
+ return;
703
+ pending.delete(p.requestId);
704
+ maybeFallback({ source, url: req.url, method: req.method, status: req.status });
705
+ }
706
+ function onLoadingFailed(params) {
707
+ const p = params;
708
+ postDataWanted.delete(p?.requestId);
709
+ const req = pending.get(p.requestId);
710
+ if (!req)
711
+ return;
712
+ pending.delete(p.requestId);
713
+ maybeFallback({
714
+ source,
715
+ url: req.url,
716
+ method: req.method,
717
+ status: req.status,
718
+ errorText: p.canceled ? 'canceled' : (p.errorText || 'failed'),
719
+ });
720
+ }
721
+ const FALLBACK_HANDLERS = {
722
+ 'Network.requestWillBeSent': onRequestWillBeSent,
723
+ 'Network.responseReceived': onResponseReceived,
724
+ 'Network.loadingFinished': onLoadingFinished,
725
+ 'Network.loadingFailed': onLoadingFailed,
726
+ };
572
727
  const onMessage = (_event, method, params) => {
573
728
  // ── Primary sink: forward the raw CDP event into the DevTools front-end ──
574
729
  if (FORWARDED_METHODS.has(method)) {
@@ -576,55 +731,12 @@ export function createNetworkForwarder(bridge) {
576
731
  enqueueNative(rewritten.method, rewritten.params);
577
732
  }
578
733
  else if (REWRITE_REQUEST_ID_METHODS.has(method)) {
579
- // Methods we namespace but don't forward (dataReceived, 二期): still
580
- // resolve so the id mapping stays coherent if forwarding is added later.
734
+ // Methods we namespace but don't forward (dataReceived): still resolve
735
+ // so the id mapping stays coherent if forwarding is added later.
581
736
  rewriteRequestId(method, params, ns);
582
737
  }
583
738
  // ── Fallback bookkeeping (used only when native dispatch is unusable) ──
584
- switch (method) {
585
- case 'Network.requestWillBeSent': {
586
- const p = params;
587
- if (!p?.request)
588
- return;
589
- if (pending.size >= MAX_PENDING)
590
- pending.clear();
591
- pending.set(p.requestId, { url: p.request.url, method: p.request.method, status: 0 });
592
- break;
593
- }
594
- case 'Network.responseReceived': {
595
- const p = params;
596
- const req = pending.get(p.requestId);
597
- if (req)
598
- req.status = p.response?.status ?? 0;
599
- break;
600
- }
601
- case 'Network.loadingFinished': {
602
- const p = params;
603
- const req = pending.get(p.requestId);
604
- if (!req)
605
- return;
606
- pending.delete(p.requestId);
607
- maybeFallback({ source: 'service', url: req.url, method: req.method, status: req.status });
608
- break;
609
- }
610
- case 'Network.loadingFailed': {
611
- const p = params;
612
- const req = pending.get(p.requestId);
613
- if (!req)
614
- return;
615
- pending.delete(p.requestId);
616
- maybeFallback({
617
- source: 'service',
618
- url: req.url,
619
- method: req.method,
620
- status: req.status,
621
- errorText: p.canceled ? 'canceled' : (p.errorText || 'failed'),
622
- });
623
- break;
624
- }
625
- default:
626
- break;
627
- }
739
+ FALLBACK_HANDLERS[method]?.(params);
628
740
  };
629
741
  wc.debugger.on('message', onMessage);
630
742
  attach.add(() => {
@@ -633,44 +745,175 @@ export function createNetworkForwarder(bridge) {
633
745
  }
634
746
  catch { /* wc gone */ }
635
747
  });
636
- const onDetach = () => { if (simWc === wc) {
637
- simWc = null;
638
- } };
639
- wc.debugger.on('detach', onDetach);
640
- attach.add(() => {
641
- try {
642
- wc.debugger.removeListener('detach', onDetach);
643
- }
644
- catch { /* wc gone */ }
748
+ }
749
+ /** Drop one guest's wiring (message listener + broker lease). Attach/detach
750
+ * ownership of the underlying session is entirely the broker's concern. */
751
+ function cleanupGuest(wcId) {
752
+ const teardown = guestWired.get(wcId);
753
+ guestWired.delete(wcId);
754
+ teardown?.disposeAll();
755
+ }
756
+ /**
757
+ * A hot-reload respawn replaces the render-host `<webview>` guest with a
758
+ * fresh one (see simulator-app.tsx's ready-then-swap session commit); the
759
+ * OLD guest's wc is closed asynchronously as part of the old session's
760
+ * teardown (bridge-router's `closeSessionPages`), so there is a window
761
+ * where `wc.isDestroyed()` is still `false` but the debugger session is
762
+ * already gone (Electron reports `detach` with reason `"target closed"` —
763
+ * confirmed via instrumentation against a real respawn: every detach in
764
+ * that run carried this exact reason, one per closing guest, never a
765
+ * repeated storm on one guest). Retrying the re-attach INLINE on every
766
+ * `detach` (no delay) raced that async teardown: each re-attach attempt
767
+ * landed inside the still-closing window and immediately produced another
768
+ * `detach`, self-sustaining a tight synchronous loop (confirmed by
769
+ * instrumentation: thousands of attach/detach cycles within a couple
770
+ * seconds) until the wc finally finished closing — there is no public
771
+ * Electron event for "this wc's close is about to complete", so the only
772
+ * available signal to end the loop is `wc.isDestroyed()` itself, which the
773
+ * retry already checks. This delay does not wait for any timing to
774
+ * "settle" — it only paces the retry slower than the async close, so the
775
+ * loop's real termination check (`wc.isDestroyed()` / `disposed`) has a
776
+ * chance to observe the close having finished before the next attempt.
777
+ */
778
+ const RENDER_GUEST_REATTACH_DELAY_MS = 300;
779
+ /**
780
+ * Schedule one more `wireRenderGuest(wc)` attempt after
781
+ * `RENDER_GUEST_REATTACH_DELAY_MS`, guarded so a guest that has since been
782
+ * destroyed (or a forwarder that has since been disposed) never gets a
783
+ * stray retry. Shared by both places capture can fail to be wired: an
784
+ * established session later getting detached, AND `broker.acquire()`
785
+ * itself refusing on the very first attempt (the session is exclusively
786
+ * held elsewhere, e.g. a real Chrome DevTools window) — that second case
787
+ * used to give up permanently instead of retrying, so capture never
788
+ * recovered even once the holder released it.
789
+ *
790
+ * De-duplicated via `guestRetryGeneration`: a repeat `attachRenderGuest(wc)`
791
+ * call while one guest's retry is already pending must join the SAME
792
+ * chain, not start an independent second one — `guestWired` alone cannot
793
+ * catch this because it is only populated once acquire actually succeeds.
794
+ * Each schedule mints a fresh generation token; the fired timer only acts
795
+ * if its captured token is still the current one, so a stale timer whose
796
+ * generation has since been superseded silently no-ops instead of clearing
797
+ * (and forking) a newer retry's bookkeeping.
798
+ */
799
+ function scheduleRenderGuestRetry(wc) {
800
+ if (wc.isDestroyed())
801
+ return;
802
+ if (guestRetryGeneration.has(wc.id))
803
+ return;
804
+ const generation = ++nextRenderGuestRetryGeneration;
805
+ guestRetryGeneration.set(wc.id, generation);
806
+ const timer = setTimeout(() => {
807
+ if (guestRetryGeneration.get(wc.id) !== generation)
808
+ return;
809
+ guestRetryGeneration.delete(wc.id);
810
+ if (!disposed && !wc.isDestroyed())
811
+ wireRenderGuest(wc);
812
+ }, RENDER_GUEST_REATTACH_DELAY_MS);
813
+ // Best-effort: if the whole forwarder tears down before this fires, there
814
+ // is nothing to clear it from — the disposed check above is the guard.
815
+ timer.unref?.();
816
+ }
817
+ /**
818
+ * Wire one render guest for Network capture via the shared broker.
819
+ *
820
+ * The lease's `onDetach` fires on EITHER an external detach (another owner
821
+ * releasing the shared session, or a real Chrome DevTools window stealing
822
+ * it) OR the guest being destroyed (see cdp-session/index.ts) — either way
823
+ * our wiring is torn down and, if the guest is still alive, re-wired (after
824
+ * `RENDER_GUEST_REATTACH_DELAY_MS`) so capture self-heals. Previously
825
+ * `attachRenderGuest` only ever ran ONCE per guest (at webview creation,
826
+ * from `did-attach-webview`), so any detach permanently killed capture for
827
+ * that guest's remaining lifetime — nothing else ever called it again.
828
+ *
829
+ * `guestWired` alone guards re-entry once successfully wired. A repeat call
830
+ * while acquire keeps failing is deliberately NOT blocked here (only the
831
+ * TIMER that call would schedule is de-duplicated, in
832
+ * `scheduleRenderGuestRetry`) — an explicit re-attach right after a detach
833
+ * must still get its own immediate `acquire()` attempt (the exclusive
834
+ * holder may have already released it by then), not be forced to wait out
835
+ * a stale pending window.
836
+ */
837
+ function wireRenderGuest(wc) {
838
+ if (guestWired.has(wc.id))
839
+ return;
840
+ const lease = broker.acquire(wc);
841
+ if (!lease) {
842
+ // Not terminal — the exclusive holder may release the session later.
843
+ // Retry on the same cadence as the onDetach self-heal below, or this
844
+ // guest's capture would die for its whole remaining lifetime the very
845
+ // first time acquire() lost the race.
846
+ scheduleRenderGuestRetry(wc);
847
+ return;
848
+ }
849
+ // This wc is now genuinely wired — drop any stale pending-retry
850
+ // generation (e.g. from an earlier detach cycle superseded by THIS
851
+ // successful acquire) so a LATER detach can freely mint its own fresh
852
+ // generation instead of being silently swallowed by a leftover entry.
853
+ guestRetryGeneration.delete(wc.id);
854
+ const teardown = new SyncDisposableRegistry();
855
+ guestWired.set(wc.id, teardown);
856
+ wireNetworkCapture(wc, 'render', teardown, `g${wc.id}-${Date.now()}`);
857
+ const detachSub = lease.onDetach(() => {
858
+ cleanupGuest(wc.id);
859
+ scheduleRenderGuestRetry(wc);
645
860
  });
646
- const onDestroyed = () => {
647
- if (simWc === wc) {
648
- simWc = null;
649
- const ad = attachDisposables;
650
- attachDisposables = null;
651
- if (ad)
652
- void ad.disposeAll().catch(() => { });
653
- }
654
- };
655
- // Route the destroyed-teardown through the connection registry when one is
656
- // available (deterministic disposal on wc destroy / connection reset);
657
- // otherwise keep the bespoke `once('destroyed')` watcher. WHAT onDestroyed
658
- // does is unchanged — only WHERE it's registered.
659
- const reg = bridge.connections;
660
- if (reg) {
661
- const owned = reg.acquire(wc).own(onDestroyed);
662
- attach.add(() => owned.dispose());
861
+ teardown.add(() => {
862
+ detachSub.dispose();
863
+ lease.dispose();
864
+ });
865
+ void lease.send('Network.enable').catch((err) => {
866
+ console.warn('[network-forward] guest Network.enable failed:', err instanceof Error ? err.message : err);
867
+ });
868
+ }
869
+ function attachRenderGuest(wc) {
870
+ if (!wc || wc.isDestroyed())
871
+ return;
872
+ wireRenderGuest(wc);
873
+ }
874
+ /**
875
+ * Attach (or reuse) the simulator wc's shared session via the broker. The
876
+ * SAME wc is independently used by simulator-storage (DOMStorage capture)
877
+ * `broker.acquire` reuses its session rather than fighting it for exclusive
878
+ * ownership, and our own switch-away/dispose only ever releases OUR lease
879
+ * (see detachSimulator), never forces a physical detach that would kill
880
+ * simulator-storage's capture too.
881
+ */
882
+ function attachSimulator(wc) {
883
+ if (!wc || wc.isDestroyed())
884
+ return;
885
+ if (simWc === wc && !wc.isDestroyed())
886
+ return;
887
+ detachSimulator();
888
+ const lease = broker.acquire(wc);
889
+ if (!lease) {
890
+ console.warn('[network-forward] debugger session unavailable; simulator network not captured');
891
+ return;
663
892
  }
664
- else {
665
- wc.once('destroyed', onDestroyed);
666
- attach.add(() => {
893
+ simWc = wc;
894
+ simLease = lease;
895
+ const attach = new SyncDisposableRegistry();
896
+ attachDisposables = attach;
897
+ wireNetworkCapture(wc, 'service', attach, String(Date.now()));
898
+ const detachSub = lease.onDetach(() => {
899
+ if (simWc !== wc)
900
+ return;
901
+ simWc = null;
902
+ simLease = null;
903
+ // Tear down OUR OWN wiring (wireNetworkCapture's 'message' listener) —
904
+ // the broker already removed its own; without this, our listener would
905
+ // keep receiving events from a session we no longer track as "current".
906
+ const ad = attachDisposables;
907
+ attachDisposables = null;
908
+ if (ad) {
667
909
  try {
668
- wc.removeListener('destroyed', onDestroyed);
910
+ ad.disposeAll();
669
911
  }
670
- catch { /* wc gone */ }
671
- });
672
- }
673
- void wc.debugger.sendCommand('Network.enable').catch((err) => {
912
+ catch { /* best-effort teardown */ }
913
+ }
914
+ });
915
+ attach.add(() => detachSub.dispose());
916
+ void lease.send('Network.enable').catch((err) => {
674
917
  console.warn('[network-forward] Network.enable failed:', err instanceof Error ? err.message : err);
675
918
  });
676
919
  }
@@ -768,6 +1011,7 @@ export function createNetworkForwarder(bridge) {
768
1011
  if (dispatchQueue.length > 0)
769
1012
  scheduleFlush();
770
1013
  }
1014
+ registry.add(() => { disposed = true; });
771
1015
  registry.add(() => {
772
1016
  devtoolsHostDisposable?.dispose();
773
1017
  devtoolsHostDisposable = null;
@@ -780,14 +1024,40 @@ export function createNetworkForwarder(bridge) {
780
1024
  readyRetryTimer = null;
781
1025
  }
782
1026
  });
1027
+ registry.add(() => {
1028
+ bodyCache.clear();
1029
+ postDataCache.clear();
1030
+ });
1031
+ // Every debugger session (simulator + all render guests) must already be
1032
+ // detached and every 'message' listener already removed before `dispose()`
1033
+ // returns control to an un-awaited caller (every real call site is
1034
+ // `fwd.dispose()` with no `await`) — a guest event arriving in the SAME
1035
+ // tick as dispose() must never be forwarded. `registry` is a
1036
+ // SyncDisposableRegistry, so every entry below runs to completion before
1037
+ // disposeAll() returns — no ordering dependency between them.
783
1038
  registry.add(() => detachSimulator());
1039
+ registry.add(() => {
1040
+ for (const wcId of [...guestWired.keys()])
1041
+ cleanupGuest(wcId);
1042
+ });
1043
+ // Only detach sessions we self-attached if we own the broker's lifecycle —
1044
+ // a shared/injected broker keeps serving other consumers past our dispose().
1045
+ registry.add(() => {
1046
+ if (ownsBroker)
1047
+ broker.dispose();
1048
+ });
784
1049
  return {
785
1050
  attachSimulator,
786
1051
  detachSimulator,
1052
+ attachRenderGuest,
787
1053
  setDevtoolsHost: (wc) => applyDevtoolsHost(wc),
788
1054
  // report() has no observing debugger, so there's no live CDP event to push
789
1055
  // natively — surface it via the console fallback line.
790
1056
  report: (record) => forwardToConsole(record),
1057
+ bodies: {
1058
+ getResponseBody: (requestId) => bodyCache.lookup(requestId),
1059
+ getRequestPostData: (requestId) => postDataCache.lookup(requestId),
1060
+ },
791
1061
  dispose: () => registry.disposeAll(),
792
1062
  };
793
1063
  }