@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.
@@ -0,0 +1,175 @@
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
+ const NOT_FOUND = 'No resource with given identifier found';
30
+ /**
31
+ * Default per-entry size ceiling (chars). Exported so callers that need to
32
+ * pre-screen a fetch BEFORE issuing it (e.g. skipping a CDP round-trip for a
33
+ * response already known to be oversized) reference the exact same number
34
+ * `PrefetchCache`'s own default enforces — one source of truth, not two
35
+ * independently-configured limits that could silently drift apart.
36
+ */
37
+ export const DEFAULT_PER_ENTRY_MAX_CHARS = 16 * 1024 * 1024;
38
+ export class PrefetchCache {
39
+ sizeOf;
40
+ map = new Map();
41
+ maxEntries;
42
+ maxTotalChars;
43
+ perEntryMaxChars;
44
+ ttlMs;
45
+ now;
46
+ constructor(sizeOf, opts = {}) {
47
+ this.sizeOf = sizeOf;
48
+ this.maxEntries = opts.maxEntries ?? 256;
49
+ this.maxTotalChars = opts.maxTotalChars ?? 64 * 1024 * 1024;
50
+ this.perEntryMaxChars = opts.perEntryMaxChars ?? DEFAULT_PER_ENTRY_MAX_CHARS;
51
+ this.ttlMs = opts.ttlMs ?? 5 * 60_000;
52
+ this.now = opts.now ?? (() => Date.now());
53
+ }
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, fetch) {
62
+ this.sweepExpired();
63
+ const existing = this.map.get(id);
64
+ if (existing && (existing.pending || !this.isExpired(existing)))
65
+ return false;
66
+ if (existing)
67
+ this.map.delete(id);
68
+ // A synchronously-throwing fetch (destroyed webContents) is a rejection.
69
+ let fetched;
70
+ try {
71
+ fetched = fetch();
72
+ }
73
+ catch (err) {
74
+ fetched = Promise.reject(err instanceof Error ? err : new Error(String(err)));
75
+ }
76
+ const entry = {
77
+ promise: fetched,
78
+ pending: true,
79
+ failed: false,
80
+ value: null,
81
+ size: 0,
82
+ settledAt: 0,
83
+ };
84
+ entry.promise = fetched.then((v) => {
85
+ entry.pending = false;
86
+ entry.settledAt = this.now();
87
+ const size = this.sizeOf(v);
88
+ if (size > this.perEntryMaxChars) {
89
+ entry.failed = true;
90
+ throw new Error(NOT_FOUND);
91
+ }
92
+ entry.value = v;
93
+ entry.size = size;
94
+ // Refresh recency at settle time (identity-checked: a clear() while
95
+ // pending must not resurrect the entry) and enforce the bounds now
96
+ // that this entry's real size is known.
97
+ if (this.map.get(id) === entry) {
98
+ this.map.delete(id);
99
+ this.map.set(id, entry);
100
+ this.evict();
101
+ }
102
+ return v;
103
+ }, () => {
104
+ entry.pending = false;
105
+ entry.failed = true;
106
+ entry.settledAt = this.now();
107
+ throw new Error(NOT_FOUND);
108
+ });
109
+ // A never-looked-up sentinel must not surface as an unhandled rejection.
110
+ entry.promise.catch(() => { });
111
+ this.map.set(id, entry);
112
+ return true;
113
+ }
114
+ lookup(id) {
115
+ this.sweepExpired();
116
+ const e = this.map.get(id);
117
+ if (!e)
118
+ return Promise.reject(new Error(NOT_FOUND));
119
+ // A pending entry's normalized promise already carries the outcome the
120
+ // waiter needs (value, sentinel, or size overflow) — hand it out directly
121
+ // so the answer never races the map bookkeeping.
122
+ if (e.pending)
123
+ return e.promise;
124
+ if (e.failed || this.isExpired(e)) {
125
+ if (!e.failed)
126
+ this.map.delete(id);
127
+ return Promise.reject(new Error(NOT_FOUND));
128
+ }
129
+ const v = e.value;
130
+ if (v === null)
131
+ return Promise.reject(new Error(NOT_FOUND));
132
+ // A hit refreshes LRU recency.
133
+ this.map.delete(id);
134
+ this.map.set(id, e);
135
+ return Promise.resolve(v);
136
+ }
137
+ clear() {
138
+ this.map.clear();
139
+ }
140
+ get size() {
141
+ return this.map.size;
142
+ }
143
+ isExpired(e) {
144
+ return !e.pending && this.now() - e.settledAt > this.ttlMs;
145
+ }
146
+ sweepExpired() {
147
+ for (const [k, e] of this.map) {
148
+ if (this.isExpired(e))
149
+ this.map.delete(k);
150
+ }
151
+ }
152
+ /** Evict oldest SETTLED entries until both bounds hold; pending are exempt. */
153
+ evict() {
154
+ let settled = 0;
155
+ let total = 0;
156
+ for (const e of this.map.values()) {
157
+ if (!e.pending) {
158
+ settled++;
159
+ total += e.size;
160
+ }
161
+ }
162
+ if (settled <= this.maxEntries && total <= this.maxTotalChars)
163
+ return;
164
+ for (const [k, e] of this.map) {
165
+ if (settled <= this.maxEntries && total <= this.maxTotalChars)
166
+ break;
167
+ if (e.pending)
168
+ continue;
169
+ this.map.delete(k);
170
+ settled--;
171
+ total -= e.size;
172
+ }
173
+ }
174
+ }
175
+ //# sourceMappingURL=body-cache.js.map
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Shared `executeJavaScript` builders for pushing raw CDP messages into the
3
+ * embedded Chrome DevTools front-end via `window.DevToolsAPI.dispatchMessage`
4
+ * / `dispatchMessageChunk`. Both network-forward (Network tab injection) and
5
+ * elements-forward (Elements tab / render-event re-injection) need the exact
6
+ * same transport contract; a single copy means the two can never drift on the
7
+ * chunk-continuation protocol.
8
+ */
9
+ /** Probe the front-end realm exposes `DevToolsAPI.dispatchMessage`. */
10
+ export declare const PROBE_DEVTOOLS_API = "(window.DevToolsAPI && typeof window.DevToolsAPI.dispatchMessage === 'function')";
11
+ /** A single dispatch payload may not exceed this many UTF-16 chars; chunk above. */
12
+ export declare const MAX_SINGLE_DISPATCH_CHARS = 1000000;
13
+ export declare const CHUNK_CHARS: number;
14
+ /**
15
+ * Build the `executeJavaScript` source for a chunked dispatch of one large CDP
16
+ * message — DevTools' own transport caps a single `dispatchMessage` payload, so
17
+ * the front-end exposes `dispatchMessageChunk(messageChunk, messageSize)` where
18
+ * the FIRST chunk carries the total size and EVERY SUBSEQUENT chunk is called
19
+ * with the chunk ONLY (second arg omitted). This matches Chromium's
20
+ * `devtools_ui_bindings.cc` DispatchProtocolMessage, whose front-end treats a
21
+ * call with a second argument as the start of a new message and a call without
22
+ * one as a continuation. Passing 0 (the previous behaviour) risked the
23
+ * front-end mis-reading a continuation as a fresh 0-size message. Returns false
24
+ * when the API isn't available.
25
+ */
26
+ export declare function buildChunkedDispatchScript(chunks: string[], totalSize: number): string;
27
+ //# sourceMappingURL=frontend-dispatch.d.ts.map
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Shared `executeJavaScript` builders for pushing raw CDP messages into the
3
+ * embedded Chrome DevTools front-end via `window.DevToolsAPI.dispatchMessage`
4
+ * / `dispatchMessageChunk`. Both network-forward (Network tab injection) and
5
+ * elements-forward (Elements tab / render-event re-injection) need the exact
6
+ * same transport contract; a single copy means the two can never drift on the
7
+ * chunk-continuation protocol.
8
+ */
9
+ /** Probe the front-end realm exposes `DevToolsAPI.dispatchMessage`. */
10
+ export const PROBE_DEVTOOLS_API = `(window.DevToolsAPI && typeof window.DevToolsAPI.dispatchMessage === 'function')`;
11
+ /** A single dispatch payload may not exceed this many UTF-16 chars; chunk above. */
12
+ export const MAX_SINGLE_DISPATCH_CHARS = 1_000_000;
13
+ export const CHUNK_CHARS = 256 * 1024;
14
+ /**
15
+ * Build the `executeJavaScript` source for a chunked dispatch of one large CDP
16
+ * message — DevTools' own transport caps a single `dispatchMessage` payload, so
17
+ * the front-end exposes `dispatchMessageChunk(messageChunk, messageSize)` where
18
+ * the FIRST chunk carries the total size and EVERY SUBSEQUENT chunk is called
19
+ * with the chunk ONLY (second arg omitted). This matches Chromium's
20
+ * `devtools_ui_bindings.cc` DispatchProtocolMessage, whose front-end treats a
21
+ * call with a second argument as the start of a new message and a call without
22
+ * one as a continuation. Passing 0 (the previous behaviour) risked the
23
+ * front-end mis-reading a continuation as a fresh 0-size message. Returns false
24
+ * when the API isn't available.
25
+ */
26
+ export function buildChunkedDispatchScript(chunks, totalSize) {
27
+ const arr = JSON.stringify(chunks);
28
+ return `(()=>{try{`
29
+ + `if(!(window.DevToolsAPI&&typeof window.DevToolsAPI.dispatchMessageChunk==='function'))return false;`
30
+ + `const cs=JSON.parse(${JSON.stringify(arr)});`
31
+ + `for(let i=0;i<cs.length;i++){`
32
+ + `try{`
33
+ // First chunk: (chunk, totalSize). Subsequent chunks: (chunk) — second arg
34
+ // omitted, NOT 0, per Chromium's continuation contract.
35
+ + `if(i===0){window.DevToolsAPI.dispatchMessageChunk(cs[i], ${totalSize})}`
36
+ + `else{window.DevToolsAPI.dispatchMessageChunk(cs[i])}`
37
+ + `}catch(_){}`
38
+ + `}`
39
+ + `return true;`
40
+ + `}catch(_){return false}})()`;
41
+ }
42
+ //# sourceMappingURL=frontend-dispatch.js.map
@@ -49,21 +49,59 @@
49
49
  * ✅ Forwarded to the native Network tab: requestWillBeSent, responseReceived,
50
50
  * loadingFinished, loadingFailed (+ requestWillBeSentExtraInfo /
51
51
  * responseReceivedExtraInfo when present, for accurate headers/status).
52
- * TODO (二期): `dataReceived` per-chunk forwarding (skipped to avoid an
53
- * executeJavaScript per chunk), and response body / preview (needs hooking
54
- * `InspectorFrontendHost.sendMessageToBackend` + a prefetched body cache so
55
- * the front-end's `Network.getResponseBody` round-trip resolves).
56
- * ⚠️ NOT captured here: requests issued from inside a render-host `<webview>`
57
- * guest (page-level resource loads / page `fetch`). The safe-area service
58
- * already owns each guest's `wc.debugger`, and `debugger.attach` is
59
- * single-owner bringing those in needs a shared CDP broker. `source:'render'`
60
- * is reserved for when that's added (later).
52
+ * Response body / preview and request post data: prefetched from the
53
+ * simulator's CDP session at loadingFinished into a bounded cache keyed by
54
+ * the VIRTUAL id (`bodies`, see NetworkBodyProvider). The front-end's
55
+ * `Network.getResponseBody` / `Network.getRequestPostData` round-trips for
56
+ * `dimina:sim:` ids are intercepted by the outbound CDP gate that
57
+ * elements-forward installs on `InspectorFrontendHost.sendMessageToBackend`
58
+ * and answered from that cache the service-host backend the front-end
59
+ * natively talks to has never heard of these ids.
60
+ * TODO: `dataReceived` per-chunk forwarding (skipped to avoid an
61
+ * executeJavaScript per chunk).
62
+ * ✅ Requests issued from inside a render-host `<webview>` guest (page-level
63
+ * resource loads / page `fetch`, tagged `source: 'render'`) — `attachRenderGuest`
64
+ * acquires a lease from the shared `CdpSessionBroker` (cdp-session/index.ts),
65
+ * which is single owner of every render-guest `wc.debugger` session across
66
+ * this forwarder, safe-area, elements-forward and render-inspect. An
67
+ * external detach (another owner releasing the shared session, or a real
68
+ * Chrome DevTools window) self-heals via the lease's `onDetach` — capture
69
+ * re-wires itself even though `attachRenderGuest` is only ever called ONCE
70
+ * per guest (at webview creation).
61
71
  * ⚠️ NOT observable by any `webContents.debugger`: a request a host module issues
62
72
  * directly from the MAIN process. Those need an explicit `report()` call
63
73
  * (exposed below; it uses the console fallback path).
64
74
  */
65
75
  import type { WebContents } from 'electron';
66
76
  import { type ConnectionRegistry, type Disposable } from '@dimina-kit/electron-deck/main';
77
+ import { type CdpSessionBroker } from '../cdp-session/index.js';
78
+ /**
79
+ * Namespace prefix of every virtual requestId this forwarder injects into the
80
+ * DevTools front-end. SINGLE SOURCE for the literal: the front-end outbound
81
+ * gate (elements-forward) keys its `Network.getResponseBody` /
82
+ * `Network.getRequestPostData` interception on this exact prefix, so the two
83
+ * modules can never drift apart on what counts as "one of ours".
84
+ */
85
+ export declare const VIRTUAL_REQUEST_ID_PREFIX = "dimina:sim:";
86
+ /** CDP `Network.getResponseBody` result shape (served from the prefetch cache). */
87
+ export interface CdpResponseBody {
88
+ body: string;
89
+ base64Encoded: boolean;
90
+ }
91
+ /**
92
+ * Answers the front-end's body/post-data lookups for virtual requestIds. The
93
+ * outbound CDP gate (elements-forward) consumes this: it intercepts the
94
+ * front-end's `Network.getResponseBody` / `Network.getRequestPostData` for
95
+ * `dimina:sim:` ids (the service-host backend has never heard of them) and
96
+ * replies from here instead. Rejections carry the same not-found message a
97
+ * real CDP backend produces for an unknown requestId.
98
+ */
99
+ export interface NetworkBodyProvider {
100
+ getResponseBody(requestId: string): Promise<CdpResponseBody>;
101
+ getRequestPostData(requestId: string): Promise<{
102
+ postData: string;
103
+ }>;
104
+ }
67
105
  /** Which layer a captured request came from (tags the fallback log line). */
68
106
  export type NetworkSource = 'service' | 'render';
69
107
  /** A normalized completed network request, used only by the console fallback. */
@@ -98,6 +136,18 @@ export interface NetworkForwarderBridge {
98
136
  * `once('destroyed')` fallback is used, so existing callers compile unchanged.
99
137
  */
100
138
  connections?: ConnectionRegistry;
139
+ /**
140
+ * Shared CDP session broker (see cdp-session/index.ts) that owns every
141
+ * render-guest AND simulator debugger session's attach/detach lifecycle —
142
+ * safe-area, elements-forward, render-inspect and simulator-storage acquire
143
+ * leases from the same instance. Absent → a private broker is created and
144
+ * owned for this forwarder's lifetime (torn down on `dispose()`), so
145
+ * existing standalone callers/tests compile and behave unchanged, just
146
+ * without cross-module session sharing. Both `attachRenderGuest` and
147
+ * `attachSimulator` go through it — see detachSimulator's docstring for why
148
+ * the simulator path never forces a physical detach either.
149
+ */
150
+ broker?: CdpSessionBroker;
101
151
  }
102
152
  export interface NetworkForwarder extends Disposable {
103
153
  /**
@@ -109,6 +159,20 @@ export interface NetworkForwarder extends Disposable {
109
159
  attachSimulator(wc: WebContents): void;
110
160
  /** Detach from the current simulator WCV (without disposing the forwarder). */
111
161
  detachSimulator(): void;
162
+ /**
163
+ * Wire a render-host guest wc (pageFrame) for Network capture — page-level
164
+ * resource loads (images/fonts/page fetch) that never touch the simulator's
165
+ * network stack. Idempotent per wc. Acquires a lease from the shared
166
+ * `CdpSessionBroker` (cdp-session/index.ts) — reuses an already-attached
167
+ * session (safe-area / elements-forward / render-inspect may have attached
168
+ * first) or self-attaches when no one has; the broker alone decides who may
169
+ * detach. Self-heals after an external detach (the lease's `onDetach`
170
+ * re-wires automatically) even though this is only ever called ONCE per
171
+ * guest, at webview creation. Events share the simulator capture's
172
+ * virtual-id namespace prefix (distinct epochs keep them collision-free) and
173
+ * the same body prefetch cache.
174
+ */
175
+ attachRenderGuest(wc: WebContents): void;
112
176
  /**
113
177
  * Point the forwarder at the WebContents hosting the DevTools FRONT-END (the
114
178
  * primary, native-Network-tab sink). Pass null when that view is torn down so
@@ -120,6 +184,13 @@ export interface NetworkForwarder extends Disposable {
120
184
  * (e.g. a main-process direct send). Uses the console fallback sink.
121
185
  */
122
186
  report(record: NetworkRequestRecord): void;
187
+ /**
188
+ * Body/post-data lookups for the virtual requestIds this forwarder injected,
189
+ * backed by the loadingFinished-time prefetch cache. Keyed by virtual id, so
190
+ * entries stay valid across detach/re-attach (each attach epoch mints
191
+ * non-colliding ids); dispose() drops them all.
192
+ */
193
+ readonly bodies: NetworkBodyProvider;
123
194
  }
124
195
  /**
125
196
  * CDP events whose `params.requestId` we rewrite into the virtual namespace.