@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.
- package/dist/main/app/app.js +3 -1
- package/dist/main/index.bundle.js +7611 -7304
- package/dist/main/services/cdp-session/index.d.ts +87 -0
- package/dist/main/services/cdp-session/index.js +173 -0
- package/dist/main/services/elements-forward/index.d.ts +132 -15
- package/dist/main/services/elements-forward/index.js +162 -229
- package/dist/main/services/network-forward/body-cache.d.ts +70 -0
- package/dist/main/services/network-forward/body-cache.js +175 -0
- package/dist/main/services/network-forward/frontend-dispatch.d.ts +27 -0
- package/dist/main/services/network-forward/frontend-dispatch.js +42 -0
- package/dist/main/services/network-forward/index.d.ts +80 -9
- package/dist/main/services/network-forward/index.js +419 -149
- package/dist/main/services/render-inspect/index.d.ts +10 -0
- package/dist/main/services/render-inspect/index.js +17 -98
- package/dist/main/services/safe-area/index.d.ts +4 -1
- package/dist/main/services/safe-area/index.js +62 -34
- package/dist/main/services/simulator-storage/index.d.ts +22 -4
- package/dist/main/services/simulator-storage/index.js +51 -64
- package/dist/main/services/views/native-simulator-devtools-host.js +12 -1
- package/dist/main/services/views/native-simulator-view.js +6 -0
- package/dist/main/services/views/view-manager.d.ts +9 -0
- package/dist/main/services/views/view-manager.js +1 -1
- package/dist/main/services/workbench-context.d.ts +9 -0
- package/dist/main/services/workbench-context.js +3 -0
- package/dist/native-host/common/common.js +154 -58
- package/dist/native-host/container/pageFrame.css +1 -1
- package/dist/native-host/render/render.js +6578 -3291
- package/dist/native-host/service/service.js +2 -2
- package/package.json +5 -5
|
@@ -1,6 +1,17 @@
|
|
|
1
|
-
import {
|
|
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 =
|
|
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
|
|
242
|
-
|
|
243
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
|
580
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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
|
-
|
|
910
|
+
ad.disposeAll();
|
|
669
911
|
}
|
|
670
|
-
catch { /*
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
|
|
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
|
}
|