cdp-toolkit 1.9.0 → 1.9.4

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.
@@ -38,11 +38,47 @@
38
38
  * reload-driven via `captureWindow`. `startRecorder` resolves only AFTER the CDP
39
39
  * domains are enabled, so a caller can trigger navigation/reload immediately and
40
40
  * be sure events are caught.
41
+ *
42
+ * SERVICE WORKERS (1.9.1)
43
+ * -----------------------
44
+ * A `worker:<substring>` (or bare worker-id) target records the same two domains
45
+ * over the worker's OWN connection, which is the whole point of the feature: the
46
+ * outbound fetch an MV3 background worker makes is a Network event on that
47
+ * session and nothing else can see it reliably. Measured on Chrome 151.0.7922.109
48
+ * before any of this was written:
49
+ *
50
+ * - `Network.enable` succeeds on a direct worker connection and
51
+ * requestWillBeSent / responseReceived / loadingFinished all arrive for a
52
+ * fetch the worker performs; `Network.getResponseBody` serves its body.
53
+ * - `Runtime.consoleAPICalled` delivers the worker's console.log on the same
54
+ * session. (`Log.enable` also succeeds but emits nothing there.)
55
+ * - `Page.enable` and `Page.reload` DO NOT EXIST on a worker session: both
56
+ * answer `-32601 ... wasn't found`. So a worker capture window cannot be
57
+ * reload-driven, and this module skips both instead of catching a failure —
58
+ * the window simply LISTENS for durationMs while the worker runs.
59
+ * - A held session SUPPRESSES MV3 idle-eviction (measured: alive and still
60
+ * emitting Network events after 60s held; evicted ~30s after the detach). A
61
+ * recording therefore keeps the worker alive for exactly as long as it runs,
62
+ * which is a documented side effect of these tools, not an accident.
63
+ * - An evicted worker that is woken has ALREADY run its top-level code by the
64
+ * time the recorder attaches, so a request the worker makes at startup can be
65
+ * missed. Trigger the work you want to observe during the window.
41
66
  */
42
67
  import { mkdir, appendFile, writeFile, copyFile } from "node:fs/promises";
43
- import { openPage, resolveTarget } from "../client.ts";
68
+ import { CdpError, listTargets, openPage, resolveTarget } from "../client.ts";
44
69
  import type { CdpConnection } from "../client.ts";
70
+ import { resolveWorkerSelector } from "../cdp/workers.ts";
45
71
  import type { Target, TargetSelector } from "../types.ts";
72
+ import {
73
+ isWorkerSelector,
74
+ isWorkerTargetType,
75
+ resolveWorkerTargets,
76
+ workerAmbiguityMessage,
77
+ workerBufferReadMissMessage,
78
+ workerNeedle,
79
+ workerWakeMisuseMessage,
80
+ WORKER_EMPTY_NEEDLE_MESSAGE,
81
+ } from "../workers.ts";
46
82
 
47
83
  /** Artifact / buffer directory (shared with the rest of the toolkit). */
48
84
  export const ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
@@ -62,6 +98,52 @@ function delay(ms: number): Promise<void> {
62
98
  return new Promise((r) => setTimeout(r, ms));
63
99
  }
64
100
 
101
+ /* ------------------------- worker targets (1.9.1) ------------------------- */
102
+
103
+ /**
104
+ * Resolve the target for a console/network call, which is the ONE place the
105
+ * three reader tools differ from every other tool: a capture may start a
106
+ * stopped worker, a read never may.
107
+ *
108
+ * `capture: true` — a recording is about to be made, so a `worker:` selector
109
+ * goes through the waking resolver (Chrome-only, `cdp/workers.ts`), default
110
+ * wake:true, exactly like evaluate_script.
111
+ * `capture: false` — an existing buffer is about to be read, so the same
112
+ * selector resolves against the RUNNING workers only, and a miss gets the
113
+ * read-path message rather than a promise that waking would help (it would
114
+ * not: the buffer is keyed by a target id the restart re-mints).
115
+ *
116
+ * Non-worker selectors are untouched: they go through client.ts's resolveTarget,
117
+ * lease gate and all.
118
+ */
119
+ export async function resolveRecorderTarget(
120
+ target: TargetSelector,
121
+ opts: { capture: boolean; wake?: boolean },
122
+ ): Promise<Target> {
123
+ if (!isWorkerSelector(target)) return resolveTarget(target);
124
+ if (opts.capture) return resolveWorkerSelector(target, { wake: opts.wake ?? true });
125
+
126
+ const needle = workerNeedle(target);
127
+ if (needle === "") throw new CdpError(WORKER_EMPTY_NEEDLE_MESSAGE);
128
+ const { matches, liveWorkers } = resolveWorkerTargets(await listTargets(), needle);
129
+ if (matches.length > 1) throw new CdpError(workerAmbiguityMessage(needle, matches));
130
+ if (matches.length === 1) return matches[0]!;
131
+ throw new CdpError(workerBufferReadMissMessage(needle, liveWorkers));
132
+ }
133
+
134
+ /**
135
+ * Validate `wake` for the three reader tools. It is REFUSED where it would do
136
+ * nothing rather than accepted and ignored — the evaluate_script precedent: an
137
+ * accepted argument reads as "this call will wake something", and a caller who
138
+ * is wrong about that never finds out.
139
+ */
140
+ export function assertWakeApplies(target: TargetSelector, wake: boolean | undefined, capture: boolean): void {
141
+ if (wake === undefined) return;
142
+ if (typeof wake !== "boolean") throw new CdpError("'wake' must be a boolean");
143
+ if (!isWorkerSelector(target)) throw new CdpError(workerWakeMisuseMessage("not-a-worker-selector"));
144
+ if (!capture) throw new CdpError(workerWakeMisuseMessage("read-only-call"));
145
+ }
146
+
65
147
  /** Network CDP events we persist. */
66
148
  const NETWORK_EVENTS = [
67
149
  "Network.requestWillBeSent",
@@ -186,11 +268,16 @@ export async function startRecorder(target: TargetSelector, opts: RecorderOption
186
268
  }
187
269
 
188
270
  /**
189
- * One-shot reload capture used by the console/network tools. Records BOTH
190
- * domains into a unique per-capture file, reloads the page, waits `durationMs`,
191
- * and (on stop) publishes the capture as the target's shared "latest" buffer.
192
- * The returned handle keeps the connection open until `stop()`, so a caller can
193
- * `flush()` then fetch response bodies before closing.
271
+ * One-shot capture used by the console/network tools. Records BOTH domains into
272
+ * a unique per-capture file, waits `durationMs`, and (on stop) publishes the
273
+ * capture as the target's shared "latest" buffer. The returned handle keeps the
274
+ * connection open until `stop()`, so a caller can `flush()` then fetch response
275
+ * bodies before closing.
276
+ *
277
+ * A PAGE window reloads to generate the traffic. A WORKER window does not (and
278
+ * cannot: `Page.*` is absent on a worker session) — it listens while the worker
279
+ * runs, and the session it holds keeps that worker from being idle-evicted for
280
+ * the length of the window.
194
281
  */
195
282
  export interface CaptureWindow {
196
283
  /** The unique per-capture buffer file (read this for isolated results). */
@@ -199,6 +286,8 @@ export interface CaptureWindow {
199
286
  conn: CdpConnection;
200
287
  /** Resolved target identity. */
201
288
  resolved: { id: string; url: string; title: string };
289
+ /** How the window was driven: "reload" for a page, "listen" for a worker. */
290
+ mode: "reload" | "listen";
202
291
  /** Buffer-write failure count. */
203
292
  droppedWrites(): number;
204
293
  /** Await in-flight appends without closing (bodies stay fetchable). */
@@ -210,9 +299,15 @@ export interface CaptureWindow {
210
299
  export async function captureWindow(
211
300
  target: TargetSelector,
212
301
  durationMs: number,
213
- opts: { timeoutMs?: number } = {},
302
+ opts: { timeoutMs?: number; wake?: boolean } = {},
214
303
  ): Promise<CaptureWindow> {
215
- const resolved = await resolveTarget(target);
304
+ const resolved = await resolveRecorderTarget(target, { capture: true, wake: opts.wake });
305
+ // A WORKER window is "listen", not "reload": Page.enable/Page.reload answer
306
+ // -32601 on a worker session (measured), and there is nothing to reload
307
+ // anyway — the caller triggers the worker's work during the window. Keyed off
308
+ // the RESOLVED TYPE rather than the selector shape, so a bare worker target id
309
+ // takes the same branch as `worker:<substring>`.
310
+ const isWorker = isWorkerTargetType(resolved.type);
216
311
  const captureId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
217
312
  const file = captureFile(resolved.id, captureId);
218
313
  // Record both domains so neither tool clobbers the other's history.
@@ -223,13 +318,16 @@ export async function captureWindow(
223
318
  file,
224
319
  timeoutMs: opts.timeoutMs,
225
320
  });
226
- await rec.conn.send("Page.enable");
227
- await rec.conn.send("Page.reload", { ignoreCache: false });
321
+ if (!isWorker) {
322
+ await rec.conn.send("Page.enable");
323
+ await rec.conn.send("Page.reload", { ignoreCache: false });
324
+ }
228
325
  await delay(durationMs);
229
326
  return {
230
327
  file: rec.file,
231
328
  conn: rec.conn,
232
329
  resolved: { id: rec.target.id, url: rec.target.url, title: rec.target.title },
330
+ mode: isWorker ? "listen" : "reload",
233
331
  droppedWrites: rec.droppedWrites,
234
332
  flush: () => rec.flush(),
235
333
  async stop(): Promise<void> {
@@ -257,6 +355,8 @@ export async function captureWindow(
257
355
  * Runtime.exceptionThrown (event)
258
356
  * Log.enable
259
357
  * Log.entryAdded (event)
358
+ * Page.enable / Page.reload (PAGE capture windows only: absent on a worker)
359
+ * ServiceWorker.enable / .startWorker (via cdp/workers.ts, worker wake only)
260
360
  *
261
361
  * Parity gaps vs chrome-devtools-mcp:
262
362
  * - Buffering is to a per-target JSONL file on disk, not an in-memory ring; the
package/src/types.ts CHANGED
@@ -55,12 +55,14 @@ export interface CdpEvent<P = Record<string, unknown>> {
55
55
  * URL contains the substring (an MV3 extension's background worker is
56
56
  * `chrome-extension://<id>/<script>`, so both the extension id and the
57
57
  * script name match). NOT UNIVERSAL, unlike every arm above it: resolved
58
- * only by Chrome's pickTarget (this file's resolveTarget), accepted only by
59
- * evaluate_script, refused with a capability error on Firefox and by the
60
- * page-only resolvers. It reads the UNFILTERED target listing, because a
61
- * worker is not a page target — and a worker that is idle-evicted is in no
62
- * listing at all, which is what evaluate_script's `wake` exists for. See
63
- * src/workers.ts and src/cdp/workers.ts.
58
+ * only by Chrome's pickTarget (client.ts's resolveTarget), accepted by the
59
+ * four tools in WORKER_CAPABLE_TOOLS (evaluate_script, which evaluates in a
60
+ * worker, plus list_network_requests / get_network_request /
61
+ * list_console_messages, which record one), refused with a capability error
62
+ * on Firefox and by the page-only resolvers. It reads the UNFILTERED target
63
+ * listing, because a worker is not a page target — and a worker that is
64
+ * idle-evicted is in no listing at all, which is what `wake` exists for.
65
+ * See src/workers.ts and src/cdp/workers.ts.
64
66
  */
65
67
  export type TargetSelector = string | undefined;
66
68
 
package/src/workers.ts CHANGED
@@ -36,6 +36,25 @@
36
36
  /** Selector prefix owned by this arm. */
37
37
  export const WORKER_SELECTOR_PREFIX = "worker:";
38
38
 
39
+ /**
40
+ * THE ALLOWLIST. Every tool that accepts a `worker:<substring>` target, and the
41
+ * single source of truth for it: the refusal messages below are built from this
42
+ * list, and test/manifest-grammar-drift.test.ts asserts that no OTHER tool's
43
+ * manifest description advertises the arm. Adding a fifth tool is therefore one
44
+ * deliberate edit here, not a description that quietly grew the grammar.
45
+ *
46
+ * 1.9.0 shipped one entry (evaluate_script). 1.9.1 adds the three console/network
47
+ * readers, because the request an MV3 background worker makes to a real backend
48
+ * is exactly what a caller needs to see and `Runtime.evaluate`-based
49
+ * interception cannot reliably show them (see the tools' descriptions).
50
+ */
51
+ export const WORKER_CAPABLE_TOOLS = [
52
+ "evaluate_script",
53
+ "list_network_requests",
54
+ "get_network_request",
55
+ "list_console_messages",
56
+ ] as const;
57
+
39
58
  /**
40
59
  * CDP target types this arm resolves. `service_worker` is the MV3 case that
41
60
  * motivated the feature; `shared_worker` is included because it presents the
@@ -94,21 +113,64 @@ export const WORKER_EMPTY_NEEDLE_MESSAGE =
94
113
 
95
114
  /**
96
115
  * The Firefox refusal. A PARAM-level gap, exactly like drag's mode:'html5'
97
- * (see resolveDragMode): evaluate_script stays in tools/list on Firefox because
98
- * every page selector still works there, and only this one arm is refused.
99
- * Naming the reason matters — WebDriver BiDi has no extension-worker target
100
- * concept to map onto, so this is a protocol gap, not an unimplemented to-do.
116
+ * (see resolveDragMode): the worker-capable tools stay in tools/list on Firefox
117
+ * because every page selector still works there, and only this one arm is
118
+ * refused. Naming the reason matters — WebDriver BiDi has no extension-worker
119
+ * target concept to map onto, so this is a protocol gap, not an unimplemented
120
+ * to-do.
101
121
  */
102
122
  export const WORKER_SELECTOR_UNSUPPORTED_MESSAGE =
103
123
  "target 'worker:<substring>' selects a service/shared worker, which this backend cannot address " +
104
124
  "(WebDriver BiDi has no extension service-worker target; Capability 'worker.targets' is Chrome-only). " +
105
- "Use --browser chrome to evaluate in an extension's background worker.";
125
+ "Use --browser chrome to evaluate in, or record console/network from, an extension's background worker.";
106
126
 
107
127
  /** Refusal from the page-only resolvers (close_page / select_page / release_page). */
108
128
  export const WORKER_SELECTOR_PAGE_ONLY_MESSAGE =
109
129
  "target 'worker:<substring>' selects a service/shared worker, and this tool is page-only " +
110
- "(closing, selecting or releasing a worker is meaningless). evaluate_script is the one tool that " +
111
- "accepts a worker: target.";
130
+ "(closing, selecting or releasing a worker is meaningless). The tools that accept a worker: target are " +
131
+ `${WORKER_CAPABLE_TOOLS.join(", ")}.`;
132
+
133
+ /**
134
+ * WHY `wake` IS REFUSED RATHER THAN IGNORED ON THE READ PATH, and why a bare id
135
+ * can never be woken. Measured for 1.9.1 on Chrome 151.0.7922.109: a worker that
136
+ * idle-evicts and is then restarted comes back under a DIFFERENT target id
137
+ * (probed: C43F0A18... -> 1F7D3C59... for the same extension). The recorder keys
138
+ * its buffer file by target id, so waking a worker on a read gives you a live
139
+ * worker whose buffer is empty — "0 requests" for a capture that really did
140
+ * record some, which is a lie shaped like an answer. A wake therefore only makes
141
+ * sense when a capture is being STARTED, and only from a `worker:<substring>`
142
+ * selector, since the id of an evicted worker no longer exists to name.
143
+ */
144
+ export function workerWakeMisuseMessage(kind: "not-a-worker-selector" | "read-only-call"): string {
145
+ if (kind === "not-a-worker-selector") {
146
+ return (
147
+ "'wake' only applies to a target of the form 'worker:<substring>'. A page is never asleep, and a bare " +
148
+ "worker target id cannot be woken either: Chrome destroys the target when an MV3 worker is evicted and " +
149
+ "mints a NEW id when it restarts, so only the url-substring form can name a worker that is not running."
150
+ );
151
+ }
152
+ return (
153
+ "'wake' only applies when a capture is being started (reload:true, or includeBody with url). Reading an " +
154
+ "existing buffer cannot use a woken worker: a restarted MV3 worker gets a NEW target id, and the buffer is " +
155
+ "keyed by id, so the wake would hand back an empty capture. Re-run with reload:true to record a fresh one."
156
+ );
157
+ }
158
+
159
+ /**
160
+ * The read-path miss. Deliberately NOT workerMissMessage's wake:false text,
161
+ * which promises that "wake:true (the default)" would fix it — true for
162
+ * evaluate_script and for a capture, false here (see workerWakeMisuseMessage).
163
+ */
164
+ export function workerBufferReadMissMessage(needle: string, liveWorkers: readonly WorkerTargetLike[]): string {
165
+ const running = liveWorkers.length ? ` (running workers: ${urlList(liveWorkers)})` : " (no workers are running)";
166
+ return (
167
+ `no running worker whose url contains '${needle}'${running}. ` +
168
+ "An MV3 extension service worker is idle-evicted after seconds of inactivity and then appears in NO target " +
169
+ "listing, so this is usually a sleeping worker rather than a wrong substring. This call only READS a buffer " +
170
+ "recorded earlier, and that buffer is keyed by the worker's target id, which Chrome re-mints on restart — so " +
171
+ "re-run with reload:true, which starts the worker and records a fresh capture window."
172
+ );
173
+ }
112
174
 
113
175
  function urlList(workers: readonly WorkerTargetLike[]): string {
114
176
  return workers.map((w) => w.url).join(", ");