@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.
- package/README.md +62 -9
- package/dist/chunks/{browser-settings-menu-VWCNG4HB.js → browser-settings-menu-REIU7C3Q.js} +83 -7
- package/dist/chunks/browser-settings-menu-REIU7C3Q.js.map +7 -0
- package/dist/chunks/chunk-GTTYS5GT.js +384 -0
- package/dist/chunks/chunk-GTTYS5GT.js.map +7 -0
- package/dist/chunks/{chunk-RH6KESL3.js → chunk-SRT3UUIP.js} +473 -574
- package/dist/chunks/chunk-SRT3UUIP.js.map +7 -0
- package/dist/chunks/chunk-YJVYJ2L2.js +469 -0
- package/dist/chunks/chunk-YJVYJ2L2.js.map +7 -0
- package/dist/chunks/{menu-PLD3S3EO.js → menu-H5HMVGHI.js} +72 -39
- package/dist/chunks/menu-H5HMVGHI.js.map +7 -0
- package/dist/chunks/tools-6P7EUX37.js +964 -0
- package/dist/chunks/tools-6P7EUX37.js.map +7 -0
- package/dist/index.ts +166 -199
- package/dist/index.ts.map +4 -4
- package/package.json +4 -3
- package/src/browser-manager.ts +408 -169
- package/src/browser-settings-menu.ts +100 -5
- package/src/cdp-client.ts +347 -90
- package/src/chrome-devtools.ts +58 -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 +85 -44
- package/src/tools.ts +91 -15
- package/src/webmcp/discovery.ts +235 -0
- package/src/webmcp/policy.ts +409 -0
- package/src/webmcp/protocol.ts +426 -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
|
-
import { shutdownManagedBrowser } from "./browser-manager.js";
|
|
5
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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(
|
|
55
|
-
|
|
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
|
-
|
|
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 ===
|
|
119
|
+
const activePage = pages.find((candidate) => candidate.id === activePageId);
|
|
68
120
|
if (activePage) return activePage;
|
|
69
121
|
|
|
70
|
-
|
|
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:
|
|
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>(
|
|
95
|
-
|
|
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:
|
|
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>(
|
|
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
|
-
|
|
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
|
|
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
|
-
});
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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>(
|
|
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
|
-
|
|
204
|
-
|
|
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
|
|
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(
|
|
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
|
}
|