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