@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,87 @@
1
+ /**
2
+ * Shared `wc.debugger` (CDP) session broker for render-host guest WebContents.
3
+ *
4
+ * ── Why this exists ─────────────────────────────────────────────────────────
5
+ * `webContents.debugger` is exclusive per wc (only one `attach()` owner at a
6
+ * time). Four independent modules — safe-area, elements-forward, render-inspect
7
+ * and network-forward — each hand-roll the same "reuse if already attached,
8
+ * self-attach only if nobody has, track what I self-attached so I only detach
9
+ * my own" bookkeeping, and each installs its OWN `wc.debugger.on('message', …)`
10
+ * listener. That duplication caused two real bugs:
11
+ *
12
+ * 1. network-forward's `attachRenderGuest` runs ONCE per guest (at webview
13
+ * creation). If some OTHER module later detaches the shared session it
14
+ * happened to self-attach, network-forward's capture dies permanently —
15
+ * nothing re-attaches it (unlike safe-area/elements-forward/render-inspect,
16
+ * which lazily re-`ensure` the debugger on every use and so self-heal).
17
+ * 2. On the simulator wc, two modules each do "detach if attached" with no
18
+ * notion of the other — whichever runs last steals the other's session.
19
+ *
20
+ * This broker is the single owner of "who attached, who may detach": callers
21
+ * `acquire(wc)` a lease instead of touching `wc.debugger` directly. A session's
22
+ * lifetime tracks the wc, not any one lease — the last lease releasing does
23
+ * NOT detach (another consumer may `acquire` again at any time); only the
24
+ * broker's own top-level `dispose()` detaches sessions IT self-attached. An
25
+ * external detach (another owner, or a real Chrome DevTools window stealing the
26
+ * session) notifies every lease's `onDetach` and drops the session's
27
+ * bookkeeping so the NEXT `acquire()` attaches from scratch — closing bug #1
28
+ * structurally instead of requiring every consumer to remember to re-ensure.
29
+ */
30
+ import type { WebContents } from 'electron';
31
+ import type { ConnectionRegistry, Disposable } from '@dimina-kit/electron-deck/main';
32
+ export interface CdpSessionLease {
33
+ /** Send one CDP command on this wc's session. Rejects if the session/wc is gone. */
34
+ send(method: string, params?: object): Promise<unknown>;
35
+ /**
36
+ * Subscribe to every CDP `message` event this session receives. Multiple
37
+ * leases (from the same or different `acquire()` calls) share ONE real
38
+ * `wc.debugger.on('message', …)` listener, fanned out to every subscriber.
39
+ */
40
+ onMessage(cb: (method: string, params: unknown) => void): Disposable;
41
+ /**
42
+ * Subscribe to this session becoming unusable for any reason OTHER than the
43
+ * broker's own top-level `dispose()`: an external detach (another owner
44
+ * releasing it, or a real Chrome DevTools window stealing it) OR the wc
45
+ * itself being destroyed. Either way the broker has already dropped its
46
+ * bookkeeping for this session by the time this fires — a consumer that
47
+ * caches its lease should drop the cache entry here so its NEXT `acquire()`
48
+ * gets a fresh one instead of operating on a dead lease. The broker's own
49
+ * `dispose()` detaching a session it self-attached does NOT fire this: that
50
+ * is an intentional, expected teardown, not a surprise the caller needs to
51
+ * react to.
52
+ */
53
+ onDetach(cb: () => void): Disposable;
54
+ /**
55
+ * Enable DOM + CSS + Overlay (the render-inspection domains) once per
56
+ * session, in dependency order (`Overlay.enable` must follow `DOM.enable`'s
57
+ * resolution — Chromium rejects it otherwise; `CSS.enable` has no such
58
+ * dependency). Memoized PER SESSION (not per lease): concurrent callers on
59
+ * the same wc share one in-flight/completed handshake. Invalidated on any
60
+ * detach (self or external) so a later session re-runs it fresh.
61
+ */
62
+ ensureRenderDomains(): Promise<void>;
63
+ /**
64
+ * Release every subscription THIS lease registered via `onMessage`/
65
+ * `onDetach`. Idempotent. Never detaches the underlying session — that
66
+ * follows the wc's lifetime, not any one lease's.
67
+ */
68
+ dispose(): void;
69
+ }
70
+ export interface CdpSessionBroker {
71
+ /**
72
+ * Get-or-create a lease for `wc`'s debugger session. Returns `null` when the
73
+ * wc is already destroyed, or when the debugger is exclusively held
74
+ * elsewhere and unavailable to attach (e.g. a real Chrome DevTools window).
75
+ */
76
+ acquire(wc: WebContents): CdpSessionLease | null;
77
+ /**
78
+ * Project-level teardown: detach every session this broker itself
79
+ * self-attached (never one it merely reused). Sessions owned by someone
80
+ * else are left untouched.
81
+ */
82
+ dispose(): void;
83
+ }
84
+ export declare function createCdpSessionBroker(opts?: {
85
+ connections?: ConnectionRegistry;
86
+ }): CdpSessionBroker;
87
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,173 @@
1
+ export function createCdpSessionBroker(opts = {}) {
2
+ const sessions = new Map();
3
+ function removeDebuggerListeners(session) {
4
+ try {
5
+ session.wc.debugger.removeListener('message', session.onDbgMessage);
6
+ }
7
+ catch { /* wc gone */ }
8
+ try {
9
+ session.wc.debugger.removeListener('detach', session.onDbgDetach);
10
+ }
11
+ catch { /* wc gone */ }
12
+ }
13
+ function notifyDetach(session) {
14
+ for (const cb of [...session.detachSubs]) {
15
+ try {
16
+ cb();
17
+ }
18
+ catch { /* subscriber error is not our problem */ }
19
+ }
20
+ }
21
+ function ensureSession(wc) {
22
+ const existing = sessions.get(wc);
23
+ if (existing)
24
+ return existing;
25
+ let selfAttached = false;
26
+ try {
27
+ if (!wc.debugger.isAttached()) {
28
+ wc.debugger.attach('1.3');
29
+ selfAttached = true;
30
+ }
31
+ }
32
+ catch {
33
+ // Exclusively held elsewhere (e.g. a real Chrome DevTools window) and
34
+ // refused to share — degrade to "unavailable", same as every existing
35
+ // per-module implementation this broker replaces.
36
+ return null;
37
+ }
38
+ const session = {
39
+ wc,
40
+ selfAttached,
41
+ selfDetaching: false,
42
+ messageSubs: new Set(),
43
+ detachSubs: new Set(),
44
+ enablePromise: null,
45
+ onDbgMessage: (...args) => {
46
+ const [, method, params] = args;
47
+ for (const cb of [...session.messageSubs])
48
+ cb(method, params);
49
+ },
50
+ onDbgDetach: () => {
51
+ const wasSelfInitiated = session.selfDetaching;
52
+ session.selfDetaching = false;
53
+ session.enablePromise = null;
54
+ sessions.delete(wc);
55
+ removeDebuggerListeners(session);
56
+ try {
57
+ session.destroyedSub?.dispose();
58
+ }
59
+ catch { /* already gone */ }
60
+ if (!wasSelfInitiated)
61
+ notifyDetach(session);
62
+ },
63
+ onWcDestroyed: () => {
64
+ sessions.delete(wc);
65
+ removeDebuggerListeners(session);
66
+ // Deliberately does NOT call wc.debugger.detach(), even for a
67
+ // self-attached session: a destroyed wc's debugger is torn down by
68
+ // Electron itself, there is nothing left for us to usefully detach,
69
+ // and attempting it is an unnecessary risk (the underlying native
70
+ // objects may already be gone). Dropping our bookkeeping is enough.
71
+ // Not a broker-initiated detach — same "your lease is dead, re-acquire
72
+ // next time" signal as an external debugger detach, just for a
73
+ // different reason (the wc itself is gone, not merely the session).
74
+ notifyDetach(session);
75
+ },
76
+ };
77
+ wc.debugger.on('message', session.onDbgMessage);
78
+ wc.debugger.on('detach', session.onDbgDetach);
79
+ if (opts.connections) {
80
+ session.destroyedSub = opts.connections.acquire(wc).on('closed', session.onWcDestroyed);
81
+ }
82
+ else {
83
+ try {
84
+ wc.once('destroyed', session.onWcDestroyed);
85
+ }
86
+ catch { /* fake/minimal wc */ }
87
+ }
88
+ sessions.set(wc, session);
89
+ return session;
90
+ }
91
+ function ensureRenderDomains(session) {
92
+ if (session.enablePromise)
93
+ return session.enablePromise;
94
+ session.wc.debugger.sendCommand('CSS.enable').catch(() => { });
95
+ const p = session.wc.debugger
96
+ .sendCommand('DOM.enable')
97
+ .then(() => session.wc.debugger.sendCommand('Overlay.enable'))
98
+ .then(() => undefined)
99
+ .catch(() => {
100
+ // Guest mid-teardown or domain unavailable — drop the memo so a later
101
+ // call (e.g. after the session recovers) can retry instead of being
102
+ // stuck on a permanently-failed handshake.
103
+ session.enablePromise = null;
104
+ });
105
+ session.enablePromise = p;
106
+ return p;
107
+ }
108
+ function createLease(session) {
109
+ const ownMessageSubs = new Set();
110
+ const ownDetachSubs = new Set();
111
+ return {
112
+ // Echo the caller's exact arity through to sendCommand — a caller that
113
+ // omits params (many CDP commands take none, e.g. 'Network.enable')
114
+ // gets the identical call Electron's own API expects, not a synthetic
115
+ // `{}` second argument.
116
+ send: (method, params) => params === undefined
117
+ ? session.wc.debugger.sendCommand(method)
118
+ : session.wc.debugger.sendCommand(method, params),
119
+ onMessage: (cb) => {
120
+ session.messageSubs.add(cb);
121
+ ownMessageSubs.add(cb);
122
+ return { dispose: () => { session.messageSubs.delete(cb); ownMessageSubs.delete(cb); } };
123
+ },
124
+ onDetach: (cb) => {
125
+ session.detachSubs.add(cb);
126
+ ownDetachSubs.add(cb);
127
+ return { dispose: () => { session.detachSubs.delete(cb); ownDetachSubs.delete(cb); } };
128
+ },
129
+ ensureRenderDomains: () => ensureRenderDomains(session),
130
+ dispose: () => {
131
+ for (const cb of ownMessageSubs)
132
+ session.messageSubs.delete(cb);
133
+ for (const cb of ownDetachSubs)
134
+ session.detachSubs.delete(cb);
135
+ ownMessageSubs.clear();
136
+ ownDetachSubs.clear();
137
+ },
138
+ };
139
+ }
140
+ return {
141
+ acquire(wc) {
142
+ if (wc.isDestroyed())
143
+ return null;
144
+ const session = ensureSession(wc);
145
+ if (!session)
146
+ return null;
147
+ return createLease(session);
148
+ },
149
+ dispose() {
150
+ for (const session of [...sessions.values()]) {
151
+ if (session.selfAttached) {
152
+ session.selfDetaching = true;
153
+ try {
154
+ if (!session.wc.isDestroyed() && session.wc.debugger.isAttached()) {
155
+ session.wc.debugger.detach();
156
+ }
157
+ }
158
+ catch { /* already detached / destroyed */ }
159
+ }
160
+ // Whether detach ran above (which already self-cleans via onDbgDetach)
161
+ // or this session was externally-owned (never touched), make sure
162
+ // broker-side bookkeeping is gone either way.
163
+ sessions.delete(session.wc);
164
+ removeDebuggerListeners(session);
165
+ try {
166
+ session.destroyedSub?.dispose();
167
+ }
168
+ catch { /* already gone */ }
169
+ }
170
+ },
171
+ };
172
+ }
173
+ //# sourceMappingURL=index.js.map
@@ -1,8 +1,82 @@
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) ───────────────────────────────────────────────────
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 — the ONE outbound CDP gate every injected-panel
11
+ * feature routes through (`routeOutboundCommand` is the single decision
12
+ * table). Commands whose method is in the RENDER domain set
13
+ * (DOM/CSS/Overlay/DOMSnapshot/DOMDebugger), plus `Network.getResponseBody`
14
+ * / `Network.getRequestPostData` for `dimina:sim:` virtual requestIds
15
+ * (which only the network forwarder's prefetch cache can answer — the
16
+ * natively-inspected service host has never heard of them), are NOT passed
17
+ * to the original embedder channel — they're pushed onto
18
+ * `globalThis.__diminaElementsOutbound` tagged with their route and main
19
+ * drains that array (splice) on a poll. EVERY OTHER method
20
+ * (Runtime/Console/Page/Target/Emulation/…) calls through to the original, so
21
+ * the service-host inspection (Console, Sources) and crucially the safe-area
22
+ * `Emulation.setSafeAreaInsetsOverride` are untouched.
23
+ * • main → front-end : we re-inject responses + render-side EVENTS via
24
+ * `window.DevToolsAPI.dispatchMessage(json)` (chunked for large payloads —
25
+ * `DOM.getDocument` can be big — mirroring network-forward/index.ts).
26
+ *
27
+ * ── Render-guest CDP session (via the shared broker) ─────────────────────────
28
+ * `webContents.debugger` is single-owner per wc, so this and every other
29
+ * feature that needs a render guest's session (safe-area, render-inspect,
30
+ * network-forward) `acquire()` a lease from the shared `CdpSessionBroker`
31
+ * (see cdp-session/index.ts) instead of each hand-rolling its own attach/
32
+ * reuse/detach bookkeeping — that duplication used to mean whichever module
33
+ * happened to attach first was the de facto owner, and any other module's
34
+ * `detach()` could steal the session out from under the others. The broker is
35
+ * the single owner of "who attached, who may detach"; `DOM.enable`/
36
+ * `CSS.enable`/`Overlay.enable` never touch the safe-area `Emulation`
37
+ * override, so sharing a session this way is purely additive.
38
+ *
39
+ * ── Staleness (active-guest, NOT a generation counter) ───────────────────────
40
+ * A response/event is honoured only while its originating guest is STILL the
41
+ * active render guest — resolved fresh from the bridge per check (`isActiveWcId`),
42
+ * not snapshotted. So a late response or stray render EVENT from a guest that is
43
+ * no longer active is dropped (its in-flight command id is settled with an error
44
+ * so the front-end never leaks a pending request, and stale nodes never bleed into
45
+ * the new tree); and switching away and BACK to a previously-wired guest RESUMES
46
+ * its forwarding (a snapshot would strand it).
47
+ *
48
+ * ── Degradation ──────────────────────────────────────────────────────────────
49
+ * Hook unavailable / `DevToolsAPI` missing / no active guest → routing is simply
50
+ * inert; the Elements panel falls back to the front-end's native service-host
51
+ * DOM (the prior behaviour). Everything is try/catch + feature-detect + bounded
52
+ * polling: it never throws, never blocks, never disturbs Console/Network/safe-area.
53
+ *
54
+ * This is a production feature (no env gate, default on for the native simulator).
55
+ * The routing table (below) is self-contained; the front-end dispatch transport
56
+ * (`buildChunkedDispatchScript` + its size constants) is shared with
57
+ * network-forward via `../network-forward/frontend-dispatch.js` so the two
58
+ * can never drift on the chunk-continuation protocol.
59
+ */
1
60
  import type { WebContents } from 'electron';
2
61
  import type { ConnectionRegistry } from '@dimina-kit/electron-deck/main';
3
62
  import type { BridgeRouterHandle } from '../../ipc/bridge-router.js';
4
- /** Which target a front-end CDP command is routed to. */
5
- export type CdpRoute = 'render' | 'service';
63
+ import { type NetworkBodyProvider } from '../network-forward/index.js';
64
+ import { type CdpSessionBroker } from '../cdp-session/index.js';
65
+ /**
66
+ * Which target a front-end CDP command is routed to.
67
+ * render — the active render guest's debugger (the Elements tree).
68
+ * network — the network forwarder's prefetch cache (body/post-data lookups
69
+ * for `dimina:sim:` virtual requestIds that only exist there).
70
+ * service — the original embedder channel (the service host the front-end
71
+ * natively inspects).
72
+ */
73
+ export type CdpRoute = 'render' | 'network' | 'service';
74
+ /**
75
+ * CDP domains that target the RENDER GUEST'S document tree. A command in one of
76
+ * these is intercepted and sent to the active render guest's debugger instead of
77
+ * the service host the front-end nominally inspects.
78
+ */
79
+ export declare const RENDER_DOMAIN_PREFIXES: readonly string[];
6
80
  /**
7
81
  * Decide where a front-end CDP `method` is routed.
8
82
  *
@@ -20,12 +94,33 @@ export type CdpRoute = 'render' | 'service';
20
94
  export declare function routeByDomain(method: string): CdpRoute;
21
95
  /** True for an event method we re-inject from the render guest into the panel. */
22
96
  export declare function isRenderEventMethod(method: string): boolean;
97
+ /**
98
+ * The Network commands answered from the network forwarder's prefetch cache
99
+ * when they target a virtual requestId. Only these two: they're the panel's
100
+ * body/post-data round-trips. Every other Network.* stays on the service path
101
+ * (a virtual id there errors exactly like an unknown id would — harmless).
102
+ */
103
+ export declare const NETWORK_BODY_METHODS: readonly string[];
104
+ /**
105
+ * Full outbound routing decision — the SINGLE authority for where a front-end
106
+ * CDP command goes. `routeByDomain` covers the method-only render split; this
107
+ * adds the params-dependent network split. The hook script inlines an
108
+ * equivalent test driven by the same literals, so the two cannot drift.
109
+ *
110
+ * A non-string / missing requestId routes to 'service': only the
111
+ * `dimina:sim:` namespace is ours to answer, and the real backend is the
112
+ * correct authority for its own ids.
113
+ */
114
+ export declare function routeOutboundCommand(method: string, params: unknown): CdpRoute;
23
115
  /**
24
116
  * The JS injected into the DevTools front-end realm. Idempotent (sentinel guard).
25
- * Wraps `InspectorFrontendHost.sendMessageToBackend`: a RENDER-domain command is
26
- * pushed onto `globalThis.__diminaElementsOutbound` (drained by main) and NOT
27
- * forwarded to the original embedder channel (main answers it from the render
28
- * guest); every other command calls through to the original (service-host path).
117
+ * Wraps `InspectorFrontendHost.sendMessageToBackend` the ONE outbound CDP
118
+ * gate for every command the front-end emits. A command that routes 'render'
119
+ * or 'network' (same decision table as {@link routeOutboundCommand}) is pushed
120
+ * onto `globalThis.__diminaElementsOutbound` tagged with its route and NOT
121
+ * forwarded to the original embedder channel (main answers it — from the
122
+ * render guest or the network prefetch cache respectively); every other
123
+ * command calls through to the original (service-host path).
29
124
  *
30
125
  * Returns `'installed'` / `'already'` on success, `'partial'` while the embedder
31
126
  * global is not yet present (so the caller retries), `'error:…'` on a real fault.
@@ -43,19 +138,41 @@ export interface ElementsForwardDeps {
43
138
  */
44
139
  appId?: string;
45
140
  /**
46
- * Optional connection registry. When present, per-webContents teardowns
47
- * (the devtools front-end wc's `stop`, and each render guest's `onDestroyed`)
48
- * are routed through `connections.acquire(wc).own(cleanup)` so they fire
49
- * deterministically on wc destroy / connection reset — replacing the bespoke
50
- * `wc.once('destroyed', cleanup)` hook. Optional so existing callers compile
51
- * unchanged; absent the `once('destroyed')` fallback is used.
141
+ * Optional connection registry. When present, the devtools front-end wc's
142
+ * own `stop` teardown is routed through `connections.acquire(wc).own(cleanup)`
143
+ * so it fires deterministically on wc destroy / connection reset — replacing
144
+ * the bespoke `wc.once('destroyed', cleanup)` hook. Optional so existing
145
+ * callers compile unchanged; absent → the `once('destroyed')` fallback is
146
+ * used. Also threaded into the PRIVATE fallback broker (see `broker` below)
147
+ * when no shared broker is supplied, so that broker's own render-guest
148
+ * wc-destroy tracking is connection-routed too.
52
149
  */
53
150
  connections?: ConnectionRegistry;
151
+ /**
152
+ * Answers intercepted `Network.getResponseBody` / `Network.getRequestPostData`
153
+ * lookups for `dimina:sim:` virtual requestIds (the network forwarder's
154
+ * prefetch cache). Absent → those commands settle with the standard CDP
155
+ * not-found error, the same answer the mis-routed service-host backend gave.
156
+ */
157
+ network?: NetworkBodyProvider;
158
+ /**
159
+ * Shared CDP session broker (see cdp-session/index.ts) that owns every
160
+ * render-guest debugger session's attach/detach lifecycle — safe-area,
161
+ * render-inspect and network-forward acquire leases from the same instance.
162
+ * Absent → a private broker is created and owned for this call's lifetime
163
+ * (torn down on `stop()`), so existing standalone callers/tests compile and
164
+ * behave unchanged, just without cross-module session sharing.
165
+ */
166
+ broker?: CdpSessionBroker;
54
167
  }
55
168
  /**
56
- * Install Elements forwarding on a DevTools front-end host wc. Returns a disposer
57
- * (`stop`) that clears timers, unsubscribes render events, detaches ONLY the
58
- * debugger sessions this feature attached, and removes every listener it added.
169
+ * Install Elements forwarding on a DevTools front-end host wc. Returns a
170
+ * disposer (`stop`) that clears timers, unsubscribes render events, releases
171
+ * every broker lease this call acquired, and only when no `deps.broker` was
172
+ * supplied (this call owns its private broker) — disposes that broker too
173
+ * (which detaches ONLY the sessions it self-attached). When `deps.broker` IS
174
+ * supplied (the shared, app-wide instance), `stop()` never disposes it: other
175
+ * consumers may still be using it.
59
176
  *
60
177
  * Best-effort + defensive throughout: a destroyed wc ends the feature; every
61
178
  * executeJavaScript / sendCommand is wrapped so a torn-down guest never throws