@narumitw/pi-chrome-devtools 0.52.2 → 0.53.0

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