@narumitw/pi-chrome-devtools 0.52.3 → 0.53.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +62 -9
  2. package/dist/chunks/{browser-settings-menu-VWCNG4HB.js → browser-settings-menu-REIU7C3Q.js} +83 -7
  3. package/dist/chunks/browser-settings-menu-REIU7C3Q.js.map +7 -0
  4. package/dist/chunks/chunk-GTTYS5GT.js +384 -0
  5. package/dist/chunks/chunk-GTTYS5GT.js.map +7 -0
  6. package/dist/chunks/{chunk-RH6KESL3.js → chunk-SRT3UUIP.js} +473 -574
  7. package/dist/chunks/chunk-SRT3UUIP.js.map +7 -0
  8. package/dist/chunks/chunk-YJVYJ2L2.js +469 -0
  9. package/dist/chunks/chunk-YJVYJ2L2.js.map +7 -0
  10. package/dist/chunks/{menu-PLD3S3EO.js → menu-H5HMVGHI.js} +72 -39
  11. package/dist/chunks/menu-H5HMVGHI.js.map +7 -0
  12. package/dist/chunks/tools-6P7EUX37.js +964 -0
  13. package/dist/chunks/tools-6P7EUX37.js.map +7 -0
  14. package/dist/index.ts +166 -199
  15. package/dist/index.ts.map +4 -4
  16. package/package.json +4 -3
  17. package/src/browser-manager.ts +408 -169
  18. package/src/browser-settings-menu.ts +100 -5
  19. package/src/cdp-client.ts +347 -90
  20. package/src/chrome-devtools.ts +58 -11
  21. package/src/lazy-tools.ts +37 -4
  22. package/src/menu.ts +80 -39
  23. package/src/render.ts +12 -1
  24. package/src/runtime.ts +92 -0
  25. package/src/settings.ts +47 -10
  26. package/src/tool-names.ts +16 -1
  27. package/src/tool-selector.ts +85 -44
  28. package/src/tools.ts +91 -15
  29. package/src/webmcp/discovery.ts +235 -0
  30. package/src/webmcp/policy.ts +409 -0
  31. package/src/webmcp/protocol.ts +426 -0
  32. package/src/webmcp/tools.ts +190 -0
  33. package/dist/chunks/browser-settings-menu-VWCNG4HB.js.map +0 -7
  34. package/dist/chunks/chunk-RH6KESL3.js.map +0 -7
  35. package/dist/chunks/menu-PLD3S3EO.js.map +0 -7
@@ -1,8 +1,19 @@
1
1
  import { isAbsolute } from "node:path";
2
- import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
3
3
  import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
4
- import { shutdownManagedBrowser } from "./browser-manager.js";
5
- import { applyRuntimeBrowserSettings, state } from "./runtime.js";
4
+ import { shutdownManagedBrowser, syncManagedBrowserSettings } from "./browser-manager.js";
5
+ import {
6
+ applyAvailableChromeDevtoolsTools,
7
+ configuredChromeDevtoolsTools,
8
+ setChromeDevtoolsSessionOwner,
9
+ } from "./lazy-tools.js";
10
+ import {
11
+ applyRuntimeBrowserSettings,
12
+ applyRuntimeWebMcpSetting,
13
+ invalidateWebMcpOperations,
14
+ state,
15
+ webMcpEnabled,
16
+ } from "./runtime.js";
6
17
  import {
7
18
  type BrowserSettingsPatch,
8
19
  type BrowserSettingsSource,
@@ -10,6 +21,7 @@ import {
10
21
  parseBrowserEndpoint,
11
22
  type SettingsLoadResult,
12
23
  saveBrowserSettings,
24
+ saveWebMcpSettings,
13
25
  } from "./settings.js";
14
26
  import { sanitizeChromeDevtoolsDisplay } from "./tool-selector.js";
15
27
 
@@ -18,6 +30,7 @@ type BrowserSettingsAction =
18
30
  | "open-endpoint"
19
31
  | "save-endpoint"
20
32
  | "set-auto-launch"
33
+ | "set-webmcp"
21
34
  | "open-executable"
22
35
  | "save-executable"
23
36
  | "show-extensions";
@@ -29,9 +42,11 @@ interface BrowserSettingsMenuState {
29
42
  type CommandContext = ExtensionCommandContext;
30
43
 
31
44
  export async function showChromeDevtoolsBrowserSettings(
45
+ pi: ExtensionAPI,
32
46
  ctx: CommandContext,
33
47
  generation: number,
34
48
  ): Promise<{ closeParent: boolean }> {
49
+ setChromeDevtoolsSessionOwner(pi, ctx.sessionManager);
35
50
  const ownerSignal = state.sessionController.signal;
36
51
  const isCurrent = () => generation === state.sessionGeneration && !ownerSignal.aborted;
37
52
  const projectTrusted = ctx.isProjectTrusted();
@@ -71,6 +86,15 @@ export async function showChromeDevtoolsBrowserSettings(
71
86
  currentValue: sanitizeChromeDevtoolsDisplay(browser.executablePath ?? "Automatic"),
72
87
  action: "open-executable",
73
88
  },
89
+ {
90
+ id: "webmcp",
91
+ label: "WebMCP · Experimental",
92
+ description:
93
+ "Allow user-confirmed calls to page-provided WebMCP tools in this browser session.",
94
+ currentValue: current.load.effectiveWebMcpEnabled ? "On" : "Off",
95
+ values: ["On", "Off"],
96
+ action: "set-webmcp",
97
+ },
74
98
  {
75
99
  id: "extensions",
76
100
  label: "Unpacked extensions",
@@ -163,6 +187,26 @@ export async function showChromeDevtoolsBrowserSettings(
163
187
  );
164
188
  return { kind: "stay" };
165
189
  },
190
+ "set-webmcp": async ({ value, signal }) => {
191
+ if (value !== "On" && value !== "Off") return { kind: "rejected" };
192
+ const saved = await saveAndApplyWebMcpSetting(
193
+ pi,
194
+ ctx,
195
+ generation,
196
+ ownerSignal,
197
+ signal,
198
+ projectTrusted,
199
+ value === "On",
200
+ );
201
+ if (!saved) return { kind: "rejected" };
202
+ ctx.ui.notify(
203
+ saved.effectiveWebMcpEnabled
204
+ ? "Experimental WebMCP enabled. Every page-provided tool call requires confirmation. Choose gateway availability under Browser tools."
205
+ : "Experimental WebMCP disabled. Active WebMCP work was aborted and both gateway tools are unavailable.",
206
+ "warning",
207
+ );
208
+ return { kind: "stay" };
209
+ },
166
210
  "open-executable": () => ({ kind: "to", screen: "executable" }),
167
211
  "save-executable": async ({ value, signal }) => {
168
212
  if (value === undefined) return { kind: "rejected" };
@@ -226,11 +270,13 @@ async function saveAndApplyBrowserPatch(
226
270
  if (!isCurrent(generation, ownerSignal, actionSignal)) return false;
227
271
  await saveBrowserSettings(patch);
228
272
  if (!isCurrent(generation, ownerSignal, actionSignal)) return false;
229
- await shutdownManagedBrowser();
273
+ invalidateWebMcpOperations(ctx.sessionManager, "Chrome DevTools browser configuration changed");
274
+ await shutdownManagedBrowser(undefined, { owner: ctx.sessionManager });
230
275
  if (!isCurrent(generation, ownerSignal, actionSignal)) return false;
231
276
  const loaded = await loadSettings({ cwd: ctx.cwd, projectTrusted });
232
277
  if (!isCurrent(generation, ownerSignal, actionSignal)) return false;
233
278
  applyRuntimeBrowserSettings(loaded.effectiveBrowser, loaded.paths, projectTrusted);
279
+ syncManagedBrowserSettings(ctx.sessionManager, loaded.effectiveBrowser);
234
280
  state.settingsNotice = loaded.notice;
235
281
  return loaded;
236
282
  } catch (error) {
@@ -239,8 +285,57 @@ async function saveAndApplyBrowserPatch(
239
285
  }
240
286
  }
241
287
 
288
+ async function saveAndApplyWebMcpSetting(
289
+ pi: ExtensionAPI,
290
+ ctx: CommandContext,
291
+ generation: number,
292
+ ownerSignal: AbortSignal,
293
+ actionSignal: AbortSignal,
294
+ projectTrusted: boolean,
295
+ enabled: boolean,
296
+ ) {
297
+ const previousEnabled = webMcpEnabled(ctx.sessionManager);
298
+ const previousTools = configuredChromeDevtoolsTools(pi);
299
+ try {
300
+ await ctx.waitForIdle();
301
+ if (!isCurrent(generation, ownerSignal, actionSignal)) return false;
302
+ applyRuntimeWebMcpSetting(enabled, ctx.sessionManager);
303
+ applyAvailableChromeDevtoolsTools(pi, previousTools);
304
+ await saveWebMcpSettings(enabled);
305
+ if (!isOwnerCurrent(generation, ownerSignal)) return false;
306
+ const loaded = await loadSettings({ cwd: ctx.cwd, projectTrusted });
307
+ if (!isOwnerCurrent(generation, ownerSignal)) return false;
308
+ state.settingsNotice = loaded.notice;
309
+ return loaded;
310
+ } catch (error) {
311
+ if (!isOwnerCurrent(generation, ownerSignal)) return false;
312
+ let rollbackError: unknown;
313
+ try {
314
+ applyRuntimeWebMcpSetting(previousEnabled, ctx.sessionManager);
315
+ applyAvailableChromeDevtoolsTools(pi, previousTools);
316
+ } catch (caught) {
317
+ rollbackError = caught;
318
+ }
319
+ if (isCurrent(generation, ownerSignal, actionSignal)) {
320
+ notifySaveFailure(
321
+ ctx,
322
+ rollbackError
323
+ ? new Error(
324
+ `${formatError(error)}; WebMCP rollback failed: ${formatError(rollbackError)}`,
325
+ )
326
+ : error,
327
+ );
328
+ }
329
+ return false;
330
+ }
331
+ }
332
+
333
+ function isOwnerCurrent(generation: number, ownerSignal: AbortSignal) {
334
+ return generation === state.sessionGeneration && !ownerSignal.aborted;
335
+ }
336
+
242
337
  function isCurrent(generation: number, ownerSignal: AbortSignal, actionSignal: AbortSignal) {
243
- return generation === state.sessionGeneration && !ownerSignal.aborted && !actionSignal.aborted;
338
+ return isOwnerCurrent(generation, ownerSignal) && !actionSignal.aborted;
244
339
  }
245
340
 
246
341
  function invalidSettingsScreen(load: SettingsLoadResult) {
package/src/cdp-client.ts CHANGED
@@ -14,36 +14,84 @@ import {
14
14
  } from "./runtime.js";
15
15
 
16
16
  interface CdpResponse<T = unknown> {
17
+ error?: { code: number; data?: unknown; message: string };
17
18
  id: number;
18
19
  result?: T;
19
- error?: { code: number; message: string; data?: unknown };
20
20
  }
21
21
 
22
- export async function listPages(options: { waitMs?: number } = {}) {
22
+ interface CdpEvent {
23
+ method: string;
24
+ params?: unknown;
25
+ }
26
+
27
+ interface PendingRequest {
28
+ cleanup: () => void;
29
+ reject: (reason: unknown) => void;
30
+ resolve: (value: unknown) => void;
31
+ }
32
+
33
+ interface EventWaiter<T = unknown> {
34
+ cleanup: () => void;
35
+ method: string;
36
+ predicate: (params: unknown) => params is T;
37
+ reject: (reason: unknown) => void;
38
+ resolve: (params: T) => void;
39
+ }
40
+
41
+ export type CdpWebSocketConstructor = new (url: string | URL) => WebSocket;
42
+
43
+ export interface CdpOperationOptions {
44
+ signal?: AbortSignal;
45
+ timeoutMs?: number;
46
+ }
47
+
48
+ export interface CdpConnectOptions extends CdpOperationOptions {
49
+ webSocketConstructor?: CdpWebSocketConstructor;
50
+ }
51
+
52
+ const MAX_BUFFERED_EVENTS_PER_METHOD = 32;
53
+ const MAX_CDP_MESSAGE_BYTES = 8 * 1024 * 1024;
54
+ const activePageIds = new WeakMap<object, string>();
55
+
56
+ interface PageOperationOptions {
57
+ sessionOwner?: object;
58
+ signal?: AbortSignal;
59
+ waitMs?: number;
60
+ }
61
+
62
+ export async function listPages(options: PageOperationOptions = {}) {
23
63
  const waitMs = options.waitMs ?? DEFAULT_ENDPOINT_WAIT_MS;
24
- await ensureDevToolsEndpoint(waitMs);
25
- return withEndpointRetry(async () => {
26
- const pages = await fetchDevToolsJson<DevToolsPage[]>("/json/list");
27
- return pages.filter((page) => page.type === "page" && page.webSocketDebuggerUrl);
28
- }, waitMs);
64
+ await ensureDevToolsEndpoint(waitMs, options.signal, options.sessionOwner);
65
+ return withEndpointRetry(
66
+ async () => {
67
+ const pages = await fetchDevToolsJson<DevToolsPage[]>(
68
+ "/json/list",
69
+ { signal: options.signal },
70
+ options.sessionOwner,
71
+ );
72
+ return pages.filter((page) => page.type === "page" && page.webSocketDebuggerUrl);
73
+ },
74
+ waitMs,
75
+ options.signal,
76
+ );
29
77
  }
30
78
 
31
- export async function getPage(pageId: string) {
32
- const pages = await listPages();
79
+ export async function getPage(pageId: string, options: PageOperationOptions = {}) {
80
+ const pages = await listPages(options);
33
81
  return requirePage(pageId, pages);
34
82
  }
35
83
 
36
- export async function resolvePage(pageId?: string) {
37
- const pages = await listPages();
84
+ export async function resolvePage(pageId?: string, options: PageOperationOptions = {}) {
85
+ const pages = await listPages(options);
38
86
  if (pageId) return requirePage(pageId, pages);
39
87
 
40
- const page = resolveDefaultPage(pages);
88
+ const page = resolveDefaultPage(pages, options.sessionOwner);
41
89
  if (!page) {
42
90
  throw new Error(
43
91
  [
44
- `No Chrome pages found at ${devToolsEndpoint()}.`,
92
+ `No Chrome pages found at ${devToolsEndpoint(options.sessionOwner)}.`,
45
93
  "Use chrome_devtools_navigate with a URL to create a page, or open a Chrome tab manually.",
46
- launchHint(),
94
+ launchHint(options.sessionOwner),
47
95
  ].join("\n"),
48
96
  );
49
97
  }
@@ -51,26 +99,39 @@ export async function resolvePage(pageId?: string) {
51
99
  return page;
52
100
  }
53
101
 
54
- export async function resolvePageForNavigation(pageId?: string) {
55
- const pages = await listPages();
102
+ export async function resolvePageForNavigation(
103
+ pageId?: string,
104
+ options: PageOperationOptions = {},
105
+ ) {
106
+ const pages = await listPages(options);
56
107
  if (pageId) return { created: false, page: requirePage(pageId, pages) };
57
108
 
58
- const page = resolveDefaultPage(pages);
109
+ const page = resolveDefaultPage(pages, options.sessionOwner);
59
110
  if (page) return { created: false, page };
60
111
 
61
- return { created: true, page: await createPage("about:blank") };
112
+ return { created: true, page: await createPage("about:blank", options) };
62
113
  }
63
114
 
64
- function resolveDefaultPage(pages: DevToolsPage[]) {
65
- if (!state.activePageId) return pages[0];
115
+ function resolveDefaultPage(pages: DevToolsPage[], owner?: object) {
116
+ const activePageId = owner ? activePageIds.get(owner) : state.activePageId;
117
+ if (!activePageId) return pages[0];
66
118
 
67
- const activePage = pages.find((candidate) => candidate.id === state.activePageId);
119
+ const activePage = pages.find((candidate) => candidate.id === activePageId);
68
120
  if (activePage) return activePage;
69
121
 
70
- state.activePageId = undefined;
122
+ setActivePageId(owner, undefined);
71
123
  return pages[0];
72
124
  }
73
125
 
126
+ export function setActivePageId(owner: object | undefined, pageId: string | undefined) {
127
+ if (!owner) {
128
+ state.activePageId = pageId;
129
+ return;
130
+ }
131
+ if (pageId) activePageIds.set(owner, pageId);
132
+ else activePageIds.delete(owner);
133
+ }
134
+
74
135
  function requirePage(pageId: string, pages: DevToolsPage[]) {
75
136
  const page = pages.find((candidate) => candidate.id === pageId);
76
137
  if (page) return page;
@@ -86,15 +147,18 @@ function requirePage(pageId: string, pages: DevToolsPage[]) {
86
147
  );
87
148
  }
88
149
 
89
- export async function createPage(url: string, options: { waitMs?: number } = {}) {
150
+ export async function createPage(url: string, options: PageOperationOptions = {}) {
90
151
  const waitMs = options.waitMs ?? DEFAULT_ENDPOINT_WAIT_MS;
91
- await ensureDevToolsEndpoint(waitMs);
152
+ await ensureDevToolsEndpoint(waitMs, options.signal, options.sessionOwner);
92
153
  const page = await withEndpointRetry(
93
154
  () =>
94
- fetchDevToolsJson<DevToolsPage>(`/json/new?${encodeURIComponent(url)}`, {
95
- method: "PUT",
96
- }),
155
+ fetchDevToolsJson<DevToolsPage>(
156
+ `/json/new?${encodeURIComponent(url)}`,
157
+ { method: "PUT", signal: options.signal },
158
+ options.sessionOwner,
159
+ ),
97
160
  waitMs,
161
+ options.signal,
98
162
  );
99
163
  if (page.type !== "page" || !page.webSocketDebuggerUrl) {
100
164
  throw new Error("Chrome DevTools created a target that is not an inspectable page.");
@@ -112,17 +176,21 @@ export function formatPage(page: DevToolsPage) {
112
176
  };
113
177
  }
114
178
 
115
- export function textResult(text: string, details: unknown) {
179
+ export function textResult<T>(text: string, details: T) {
116
180
  return {
117
181
  content: [{ type: "text" as const, text }],
118
182
  details,
119
183
  };
120
184
  }
121
185
 
122
- export async function withCdp<T>(page: DevToolsPage, callback: (client: CdpClient) => Promise<T>) {
186
+ export async function withCdp<T>(
187
+ page: DevToolsPage,
188
+ callback: (client: CdpClient) => Promise<T>,
189
+ options: CdpConnectOptions = {},
190
+ ) {
123
191
  if (!page.webSocketDebuggerUrl) throw new Error(`Page has no webSocketDebuggerUrl: ${page.id}`);
124
192
 
125
- const client = await CdpClient.connect(page.webSocketDebuggerUrl);
193
+ const client = await CdpClient.connect(page.webSocketDebuggerUrl, options);
126
194
  try {
127
195
  return await callback(client);
128
196
  } finally {
@@ -130,90 +198,279 @@ export async function withCdp<T>(page: DevToolsPage, callback: (client: CdpClien
130
198
  }
131
199
  }
132
200
 
133
- class CdpClient {
201
+ export class CdpClient {
202
+ #closed = false;
203
+ #eventBuffers = new Map<string, unknown[]>();
204
+ #eventWaiters = new Set<EventWaiter>();
134
205
  #nextId = 1;
135
- #pending = new Map<
136
- number,
137
- {
138
- resolve: (value: unknown) => void;
139
- reject: (reason: unknown) => void;
140
- timeout: NodeJS.Timeout;
141
- }
142
- >();
143
- private readonly socket: WebSocket;
206
+ #pending = new Map<number, PendingRequest>();
207
+ readonly #socket: WebSocket;
144
208
 
145
209
  private constructor(socket: WebSocket) {
146
- this.socket = socket;
147
- socket.addEventListener("message", (event) => {
148
- const response = JSON.parse(String(event.data)) as CdpResponse;
149
- if (typeof response.id !== "number") return;
150
-
151
- const pending = this.#pending.get(response.id);
152
- if (!pending) return;
153
-
154
- clearTimeout(pending.timeout);
155
- this.#pending.delete(response.id);
156
-
157
- if (response.error) {
158
- pending.reject(new Error(`CDP error ${response.error.code}: ${response.error.message}`));
159
- } else {
160
- pending.resolve(response.result);
161
- }
162
- });
163
-
164
- socket.addEventListener("close", () => {
165
- this.rejectAll(new Error("Chrome DevTools WebSocket closed"));
166
- });
167
-
168
- socket.addEventListener("error", () => {
169
- this.rejectAll(new Error("Chrome DevTools WebSocket error"));
170
- });
210
+ this.#socket = socket;
211
+ socket.addEventListener("message", this.onMessage);
212
+ socket.addEventListener("close", this.onClose);
213
+ socket.addEventListener("error", this.onError);
171
214
  }
172
215
 
173
- static connect(url: string) {
216
+ static connect(url: string, options: CdpConnectOptions = {}) {
217
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
218
+ const WebSocketImplementation = options.webSocketConstructor ?? WebSocket;
219
+ options.signal?.throwIfAborted();
174
220
  return new Promise<CdpClient>((resolve, reject) => {
175
- const socket = new WebSocket(url);
176
- const timeout = setTimeout(() => {
177
- socket.close();
178
- reject(new Error(`Timed out connecting to Chrome DevTools WebSocket: ${url}`));
179
- }, DEFAULT_TIMEOUT_MS);
180
-
181
- socket.addEventListener("open", () => {
221
+ const socket = new WebSocketImplementation(url);
222
+ let settled = false;
223
+ const settle = (callback: () => void) => {
224
+ if (settled) return;
225
+ settled = true;
182
226
  clearTimeout(timeout);
183
- resolve(new CdpClient(socket));
184
- });
185
-
186
- socket.addEventListener("error", () => {
187
- clearTimeout(timeout);
188
- reject(new Error(`Failed to connect to Chrome DevTools WebSocket: ${url}`));
189
- });
227
+ options.signal?.removeEventListener("abort", onAbort);
228
+ socket.removeEventListener("open", onOpen);
229
+ socket.removeEventListener("error", onConnectError);
230
+ callback();
231
+ };
232
+ const onOpen = () => settle(() => resolve(new CdpClient(socket)));
233
+ const onConnectError = () =>
234
+ settle(() => {
235
+ socket.close();
236
+ reject(new Error(`Failed to connect to Chrome DevTools WebSocket: ${url}`));
237
+ });
238
+ const onAbort = () =>
239
+ settle(() => {
240
+ socket.close();
241
+ reject(options.signal?.reason ?? abortError("Chrome DevTools connection aborted"));
242
+ });
243
+ const timeout = setTimeout(
244
+ () =>
245
+ settle(() => {
246
+ socket.close();
247
+ reject(new Error(`Timed out connecting to Chrome DevTools WebSocket: ${url}`));
248
+ }),
249
+ timeoutMs,
250
+ );
251
+ options.signal?.addEventListener("abort", onAbort, { once: true });
252
+ socket.addEventListener("open", onOpen, { once: true });
253
+ socket.addEventListener("error", onConnectError, { once: true });
190
254
  });
191
255
  }
192
256
 
193
- send<T = unknown>(method: string, params?: Record<string, unknown>) {
257
+ send<T = unknown>(
258
+ method: string,
259
+ params: Record<string, unknown> = {},
260
+ options: CdpOperationOptions = {},
261
+ ) {
262
+ this.throwIfClosed();
263
+ options.signal?.throwIfAborted();
194
264
  const id = this.#nextId;
195
265
  this.#nextId += 1;
196
-
197
266
  return new Promise<T>((resolve, reject) => {
267
+ const onAbort = () => {
268
+ this.#pending.delete(id);
269
+ cleanup();
270
+ reject(options.signal?.reason ?? abortError(`CDP command aborted: ${method}`));
271
+ };
198
272
  const timeout = setTimeout(() => {
199
273
  this.#pending.delete(id);
274
+ options.signal?.removeEventListener("abort", onAbort);
200
275
  reject(new Error(`Timed out waiting for CDP response: ${method}`));
201
- }, DEFAULT_TIMEOUT_MS);
276
+ }, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
277
+ const cleanup = () => {
278
+ clearTimeout(timeout);
279
+ options.signal?.removeEventListener("abort", onAbort);
280
+ };
281
+ this.#pending.set(id, {
282
+ cleanup,
283
+ reject,
284
+ resolve: resolve as (value: unknown) => void,
285
+ });
286
+ options.signal?.addEventListener("abort", onAbort, { once: true });
287
+ try {
288
+ this.#socket.send(JSON.stringify({ id, method, params }));
289
+ } catch (error) {
290
+ this.#pending.delete(id);
291
+ cleanup();
292
+ reject(error);
293
+ }
294
+ });
295
+ }
202
296
 
203
- this.#pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timeout });
204
- this.socket.send(JSON.stringify({ id, method, params: params ?? {} }));
297
+ waitForEvent<T>(
298
+ method: string,
299
+ predicate: (params: unknown) => params is T,
300
+ options: CdpOperationOptions = {},
301
+ ): Promise<T> {
302
+ this.throwIfClosed();
303
+ options.signal?.throwIfAborted();
304
+ const buffered = this.#eventBuffers.get(method) ?? [];
305
+ for (const [index, params] of buffered.entries()) {
306
+ if (!predicate(params)) continue;
307
+ buffered.splice(index, 1);
308
+ if (buffered.length === 0) this.#eventBuffers.delete(method);
309
+ return Promise.resolve(params);
310
+ }
311
+
312
+ return new Promise<T>((resolve, reject) => {
313
+ const waiter = {} as EventWaiter<T>;
314
+ const onAbort = () => {
315
+ this.#eventWaiters.delete(waiter as EventWaiter);
316
+ cleanup();
317
+ reject(options.signal?.reason ?? abortError(`CDP event wait aborted: ${method}`));
318
+ };
319
+ const timeout = setTimeout(() => {
320
+ this.#eventWaiters.delete(waiter as EventWaiter);
321
+ options.signal?.removeEventListener("abort", onAbort);
322
+ reject(new Error(`Timed out waiting for CDP event: ${method}`));
323
+ }, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
324
+ const cleanup = () => {
325
+ clearTimeout(timeout);
326
+ options.signal?.removeEventListener("abort", onAbort);
327
+ };
328
+ Object.assign(waiter, { cleanup, method, predicate, reject, resolve });
329
+ this.#eventWaiters.add(waiter as EventWaiter);
330
+ options.signal?.addEventListener("abort", onAbort, { once: true });
205
331
  });
206
332
  }
207
333
 
208
- close() {
209
- this.socket.close();
334
+ close(reason: unknown = new Error("Chrome DevTools WebSocket closed")) {
335
+ if (this.#closed) return;
336
+ this.#closed = true;
337
+ this.#socket.removeEventListener("message", this.onMessage);
338
+ this.#socket.removeEventListener("close", this.onClose);
339
+ this.#socket.removeEventListener("error", this.onError);
340
+ this.rejectAll(reason);
341
+ this.#eventBuffers.clear();
342
+ try {
343
+ this.#socket.close();
344
+ } catch {
345
+ // The transport is already unusable; pending work has still been rejected.
346
+ }
347
+ }
348
+
349
+ private readonly onClose = () => {
350
+ this.close(new Error("Chrome DevTools WebSocket closed"));
351
+ };
352
+
353
+ private readonly onError = () => {
354
+ this.close(new Error("Chrome DevTools WebSocket error"));
355
+ };
356
+
357
+ private readonly onMessage = (event: MessageEvent) => {
358
+ if (typeof event.data !== "string") {
359
+ this.close(new Error("Chrome DevTools WebSocket sent an unsupported binary message"));
360
+ return;
361
+ }
362
+ if (
363
+ event.data.length > MAX_CDP_MESSAGE_BYTES ||
364
+ Buffer.byteLength(event.data, "utf8") > MAX_CDP_MESSAGE_BYTES
365
+ ) {
366
+ this.close(new Error("Chrome DevTools WebSocket message exceeds the 8 MB limit"));
367
+ return;
368
+ }
369
+ let payload: unknown;
370
+ try {
371
+ payload = JSON.parse(event.data) as unknown;
372
+ } catch {
373
+ this.close(new Error("Chrome DevTools WebSocket sent malformed JSON"));
374
+ return;
375
+ }
376
+ if (!isRecord(payload)) {
377
+ this.close(new Error("Chrome DevTools WebSocket sent a malformed message"));
378
+ return;
379
+ }
380
+ if (typeof payload.id === "number") {
381
+ this.handleResponse(payload as unknown as CdpResponse);
382
+ return;
383
+ }
384
+ if (typeof payload.method !== "string") {
385
+ this.close(new Error("Chrome DevTools WebSocket sent a malformed event"));
386
+ return;
387
+ }
388
+ this.handleEvent(payload as unknown as CdpEvent);
389
+ };
390
+
391
+ private handleResponse(response: CdpResponse) {
392
+ const pending = this.#pending.get(response.id);
393
+ if (!pending) return;
394
+ this.#pending.delete(response.id);
395
+ pending.cleanup();
396
+ if (response.error) {
397
+ pending.reject(
398
+ new Error(
399
+ `CDP error ${response.error.code}: ${response.error.message}${
400
+ response.error.data === undefined ? "" : ` (${safeJson(response.error.data)})`
401
+ }`,
402
+ ),
403
+ );
404
+ return;
405
+ }
406
+ pending.resolve(response.result);
407
+ }
408
+
409
+ private handleEvent(event: CdpEvent) {
410
+ if (event.method === "Inspector.detached" || event.method === "Target.detachedFromTarget") {
411
+ this.close(new Error(`Chrome DevTools target detached: ${safeJson(event.params)}`));
412
+ return;
413
+ }
414
+ for (const waiter of this.#eventWaiters) {
415
+ if (waiter.method !== event.method) continue;
416
+ let matched: boolean;
417
+ try {
418
+ matched = waiter.predicate(event.params);
419
+ } catch (error) {
420
+ this.#eventWaiters.delete(waiter);
421
+ waiter.cleanup();
422
+ waiter.reject(error);
423
+ return;
424
+ }
425
+ if (!matched) continue;
426
+ this.#eventWaiters.delete(waiter);
427
+ waiter.cleanup();
428
+ waiter.resolve(event.params);
429
+ return;
430
+ }
431
+ const buffer = this.#eventBuffers.get(event.method) ?? [];
432
+ buffer.push(event.params);
433
+ if (buffer.length > MAX_BUFFERED_EVENTS_PER_METHOD) {
434
+ this.close(
435
+ new Error(
436
+ `Chrome DevTools WebSocket buffered more than ${MAX_BUFFERED_EVENTS_PER_METHOD} ${event.method} events`,
437
+ ),
438
+ );
439
+ return;
440
+ }
441
+ this.#eventBuffers.set(event.method, buffer);
210
442
  }
211
443
 
212
- private rejectAll(error: Error) {
444
+ private rejectAll(reason: unknown) {
213
445
  for (const [id, pending] of this.#pending) {
214
- clearTimeout(pending.timeout);
215
- pending.reject(error);
216
446
  this.#pending.delete(id);
447
+ pending.cleanup();
448
+ pending.reject(reason);
449
+ }
450
+ for (const waiter of this.#eventWaiters) {
451
+ this.#eventWaiters.delete(waiter);
452
+ waiter.cleanup();
453
+ waiter.reject(reason);
217
454
  }
218
455
  }
456
+
457
+ private throwIfClosed() {
458
+ if (this.#closed) throw new Error("Chrome DevTools WebSocket is closed");
459
+ }
460
+ }
461
+
462
+ function abortError(message: string) {
463
+ return new DOMException(message, "AbortError");
464
+ }
465
+
466
+ function isRecord(value: unknown): value is Record<string, unknown> {
467
+ return typeof value === "object" && value !== null && !Array.isArray(value);
468
+ }
469
+
470
+ function safeJson(value: unknown) {
471
+ try {
472
+ return JSON.stringify(value);
473
+ } catch {
474
+ return String(value);
475
+ }
219
476
  }