@narumitw/pi-chrome-devtools 0.52.3 → 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.
- package/README.md +62 -9
- package/dist/chunks/{browser-settings-menu-VWCNG4HB.js → browser-settings-menu-N5O65I7S.js} +81 -7
- package/dist/chunks/browser-settings-menu-N5O65I7S.js.map +7 -0
- package/dist/chunks/chunk-B7B63PWB.js +465 -0
- package/dist/chunks/chunk-B7B63PWB.js.map +7 -0
- package/dist/chunks/{chunk-RH6KESL3.js → chunk-GVBXT3FB.js} +369 -514
- package/dist/chunks/chunk-GVBXT3FB.js.map +7 -0
- package/dist/chunks/chunk-RTECHAEZ.js +373 -0
- package/dist/chunks/chunk-RTECHAEZ.js.map +7 -0
- package/dist/chunks/{menu-PLD3S3EO.js → menu-UBHW6UP2.js} +72 -39
- package/dist/chunks/menu-UBHW6UP2.js.map +7 -0
- package/dist/chunks/tools-V7OR5KJ3.js +945 -0
- package/dist/chunks/tools-V7OR5KJ3.js.map +7 -0
- package/dist/index.ts +158 -195
- package/dist/index.ts.map +4 -4
- package/package.json +4 -3
- package/src/browser-manager.ts +282 -109
- package/src/browser-settings-menu.ts +98 -4
- package/src/cdp-client.ts +329 -83
- package/src/chrome-devtools.ts +51 -11
- package/src/lazy-tools.ts +37 -4
- package/src/menu.ts +80 -39
- package/src/render.ts +12 -1
- package/src/runtime.ts +92 -0
- package/src/settings.ts +47 -10
- package/src/tool-names.ts +16 -1
- package/src/tool-selector.ts +67 -30
- package/src/tools.ts +86 -10
- package/src/webmcp/discovery.ts +235 -0
- package/src/webmcp/policy.ts +402 -0
- package/src/webmcp/protocol.ts +414 -0
- package/src/webmcp/tools.ts +190 -0
- package/dist/chunks/browser-settings-menu-VWCNG4HB.js.map +0 -7
- package/dist/chunks/chunk-RH6KESL3.js.map +0 -7
- 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
4
|
import { shutdownManagedBrowser } from "./browser-manager.js";
|
|
5
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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(
|
|
55
|
-
|
|
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:
|
|
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>(
|
|
95
|
-
|
|
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:
|
|
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>(
|
|
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
|
-
|
|
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
|
|
147
|
-
socket.addEventListener("message",
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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>(
|
|
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
|
-
|
|
204
|
-
|
|
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
|
|
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
|
|
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
|
}
|