@narumitw/pi-chrome-devtools 0.1.28 → 0.1.30
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 +4 -2
- package/package.json +1 -1
- package/src/chrome-devtools.ts +178 -25
package/README.md
CHANGED
|
@@ -12,10 +12,12 @@ This package is inspired by [`chrome-devtools-mcp`](https://github.com/ChromeDev
|
|
|
12
12
|
|
|
13
13
|
- Lists inspectable Chrome tabs and pages.
|
|
14
14
|
- Selects an active Chrome page for later tool calls.
|
|
15
|
-
- Navigates Chrome to a target URL.
|
|
15
|
+
- Navigates Chrome to a target URL, creating an inspectable page when none exists.
|
|
16
|
+
- Recovers from stale active page selections by falling back to an available page.
|
|
16
17
|
- Evaluates JavaScript in the selected page.
|
|
17
18
|
- Captures PNG screenshots, including optional full-page screenshots.
|
|
18
19
|
- Uses a local Chrome DevTools Protocol endpoint.
|
|
20
|
+
- Retries briefly while Chrome is starting and reports actionable endpoint errors.
|
|
19
21
|
- Shows statusline activity only while Chrome DevTools tools are running.
|
|
20
22
|
|
|
21
23
|
## 📦 Install
|
|
@@ -62,7 +64,7 @@ PI_CHROME_DEVTOOLS_HOST=127.0.0.1 PI_CHROME_DEVTOOLS_PORT=9223 pi -e ./extension
|
|
|
62
64
|
|
|
63
65
|
- `chrome_devtools_list_pages` — list inspectable Chrome tabs/pages.
|
|
64
66
|
- `chrome_devtools_select_page` — select the active page for later tool calls.
|
|
65
|
-
- `chrome_devtools_navigate` — navigate a page to a URL.
|
|
67
|
+
- `chrome_devtools_navigate` — navigate a page to a URL; if no page exists, create one first.
|
|
66
68
|
- `chrome_devtools_evaluate` — evaluate JavaScript in the selected page.
|
|
67
69
|
- `chrome_devtools_screenshot` — capture a PNG screenshot.
|
|
68
70
|
|
package/package.json
CHANGED
package/src/chrome-devtools.ts
CHANGED
|
@@ -4,6 +4,9 @@ import { Type } from "typebox";
|
|
|
4
4
|
const DEFAULT_HOST = "127.0.0.1";
|
|
5
5
|
const DEFAULT_PORT = 9222;
|
|
6
6
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
7
|
+
const DEFAULT_HTTP_TIMEOUT_MS = 1_000;
|
|
8
|
+
const DEFAULT_ENDPOINT_WAIT_MS = 5_000;
|
|
9
|
+
const DEFAULT_ENDPOINT_RETRY_MS = 250;
|
|
7
10
|
const STATUS_KEY = "chrome-devtools";
|
|
8
11
|
|
|
9
12
|
interface StatusContext {
|
|
@@ -75,8 +78,9 @@ const selectPageTool = defineTool({
|
|
|
75
78
|
const navigateTool = defineTool({
|
|
76
79
|
name: "chrome_devtools_navigate",
|
|
77
80
|
label: "Chrome DevTools: Navigate",
|
|
78
|
-
description:
|
|
79
|
-
|
|
81
|
+
description:
|
|
82
|
+
"Navigate a Chrome page to a URL through Chrome DevTools Protocol, creating a page first if none is available.",
|
|
83
|
+
promptSnippet: "Navigate the selected or first Chrome tab to a URL, creating one if needed",
|
|
80
84
|
parameters: Type.Object({
|
|
81
85
|
url: Type.String({ description: "URL to navigate to." }),
|
|
82
86
|
pageId: Type.Optional(
|
|
@@ -85,14 +89,19 @@ const navigateTool = defineTool({
|
|
|
85
89
|
}),
|
|
86
90
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
87
91
|
return withStatus(ctx, "🌐 navigate", async () => {
|
|
88
|
-
const page = await
|
|
92
|
+
const { created, page } = await resolvePageForNavigation(params.pageId);
|
|
89
93
|
const result = await withCdp(page, async (client) => {
|
|
90
94
|
await client.send("Page.enable");
|
|
91
95
|
return client.send("Page.navigate", { url: params.url });
|
|
92
96
|
});
|
|
93
97
|
|
|
94
98
|
state.activePageId = page.id;
|
|
95
|
-
|
|
99
|
+
const action = created ? "Created page and navigated" : "Navigated";
|
|
100
|
+
return textResult(`${action} ${page.id} to ${params.url}`, {
|
|
101
|
+
created,
|
|
102
|
+
page: formatPage(page),
|
|
103
|
+
result,
|
|
104
|
+
});
|
|
96
105
|
});
|
|
97
106
|
},
|
|
98
107
|
});
|
|
@@ -190,10 +199,7 @@ export default function chromeDevtools(pi: ExtensionAPI) {
|
|
|
190
199
|
pi.registerCommand("chrome-devtools", {
|
|
191
200
|
description: "Show Chrome DevTools endpoint and quick start help",
|
|
192
201
|
handler: async (_args, ctx) => {
|
|
193
|
-
ctx.ui.notify(
|
|
194
|
-
`Chrome DevTools endpoint: http://${state.host}:${state.port}. Start Chrome with --remote-debugging-port=${state.port}.`,
|
|
195
|
-
"info",
|
|
196
|
-
);
|
|
202
|
+
ctx.ui.notify(`Chrome DevTools endpoint: ${devToolsEndpoint()}\n${launchHint()}`, "info");
|
|
197
203
|
},
|
|
198
204
|
});
|
|
199
205
|
|
|
@@ -206,38 +212,183 @@ export default function chromeDevtools(pi: ExtensionAPI) {
|
|
|
206
212
|
});
|
|
207
213
|
}
|
|
208
214
|
|
|
209
|
-
async function listPages() {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
const pages = (await response.json()) as DevToolsPage[];
|
|
216
|
-
return pages.filter((page) => page.type === "page" && page.webSocketDebuggerUrl);
|
|
215
|
+
async function listPages(options: { waitMs?: number } = {}) {
|
|
216
|
+
return withEndpointRetry(async () => {
|
|
217
|
+
const pages = await fetchDevToolsJson<DevToolsPage[]>("/json/list");
|
|
218
|
+
return pages.filter((page) => page.type === "page" && page.webSocketDebuggerUrl);
|
|
219
|
+
}, options.waitMs ?? DEFAULT_ENDPOINT_WAIT_MS);
|
|
217
220
|
}
|
|
218
221
|
|
|
219
222
|
async function getPage(pageId: string) {
|
|
220
223
|
const pages = await listPages();
|
|
221
|
-
|
|
222
|
-
if (!page) throw new Error(`Chrome DevTools page not found: ${pageId}`);
|
|
223
|
-
return page;
|
|
224
|
+
return requirePage(pageId, pages);
|
|
224
225
|
}
|
|
225
226
|
|
|
226
227
|
async function resolvePage(pageId?: string) {
|
|
227
|
-
if (pageId) return getPage(pageId);
|
|
228
|
-
if (state.activePageId) return getPage(state.activePageId);
|
|
229
|
-
|
|
230
228
|
const pages = await listPages();
|
|
231
|
-
|
|
229
|
+
if (pageId) return requirePage(pageId, pages);
|
|
230
|
+
|
|
231
|
+
const page = resolveDefaultPage(pages);
|
|
232
232
|
if (!page) {
|
|
233
233
|
throw new Error(
|
|
234
|
-
|
|
234
|
+
[
|
|
235
|
+
`No Chrome pages found at ${devToolsEndpoint()}.`,
|
|
236
|
+
"Use chrome_devtools_navigate with a URL to create a page, or open a Chrome tab manually.",
|
|
237
|
+
launchHint(),
|
|
238
|
+
].join("\n"),
|
|
235
239
|
);
|
|
236
240
|
}
|
|
237
241
|
|
|
238
242
|
return page;
|
|
239
243
|
}
|
|
240
244
|
|
|
245
|
+
async function resolvePageForNavigation(pageId?: string) {
|
|
246
|
+
const pages = await listPages();
|
|
247
|
+
if (pageId) return { created: false, page: requirePage(pageId, pages) };
|
|
248
|
+
|
|
249
|
+
const page = resolveDefaultPage(pages);
|
|
250
|
+
if (page) return { created: false, page };
|
|
251
|
+
|
|
252
|
+
return { created: true, page: await createPage("about:blank") };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function resolveDefaultPage(pages: DevToolsPage[]) {
|
|
256
|
+
if (!state.activePageId) return pages[0];
|
|
257
|
+
|
|
258
|
+
const activePage = pages.find((candidate) => candidate.id === state.activePageId);
|
|
259
|
+
if (activePage) return activePage;
|
|
260
|
+
|
|
261
|
+
state.activePageId = undefined;
|
|
262
|
+
return pages[0];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function requirePage(pageId: string, pages: DevToolsPage[]) {
|
|
266
|
+
const page = pages.find((candidate) => candidate.id === pageId);
|
|
267
|
+
if (page) return page;
|
|
268
|
+
|
|
269
|
+
const availablePages = pages.map(formatPageListItem).join("\n");
|
|
270
|
+
throw new Error(
|
|
271
|
+
[
|
|
272
|
+
`Chrome DevTools page not found: ${pageId}.`,
|
|
273
|
+
availablePages
|
|
274
|
+
? `Available pages:\n${availablePages}`
|
|
275
|
+
: "No inspectable Chrome pages are currently available.",
|
|
276
|
+
].join("\n"),
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function createPage(url: string) {
|
|
281
|
+
const page = await fetchDevToolsJson<DevToolsPage>(`/json/new?${encodeURIComponent(url)}`, {
|
|
282
|
+
method: "PUT",
|
|
283
|
+
});
|
|
284
|
+
if (page.type !== "page" || !page.webSocketDebuggerUrl) {
|
|
285
|
+
throw new Error("Chrome DevTools created a target that is not an inspectable page.");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return page;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function fetchDevToolsJson<T>(path: string, init?: RequestInit) {
|
|
292
|
+
const url = `${devToolsEndpoint()}${path}`;
|
|
293
|
+
let response: Response;
|
|
294
|
+
try {
|
|
295
|
+
response = await fetch(url, {
|
|
296
|
+
...init,
|
|
297
|
+
signal: AbortSignal.timeout(DEFAULT_HTTP_TIMEOUT_MS),
|
|
298
|
+
});
|
|
299
|
+
} catch (error) {
|
|
300
|
+
throw new DevToolsEndpointError(endpointConnectionErrorMessage(error), { retryable: true });
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (!response.ok) {
|
|
304
|
+
const body = (await response.text().catch(() => "")).trim();
|
|
305
|
+
const suffix = body ? `: ${body.slice(0, 200)}` : "";
|
|
306
|
+
throw new DevToolsEndpointError(
|
|
307
|
+
[
|
|
308
|
+
`Chrome DevTools endpoint ${url} returned ${response.status} ${response.statusText}${suffix}.`,
|
|
309
|
+
endpointConfigHint(),
|
|
310
|
+
].join("\n"),
|
|
311
|
+
{ retryable: response.status === 429 || response.status >= 500 },
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return (await response.json()) as T;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function withEndpointRetry<T>(operation: () => Promise<T>, waitMs: number) {
|
|
319
|
+
const deadline = Date.now() + waitMs;
|
|
320
|
+
while (true) {
|
|
321
|
+
try {
|
|
322
|
+
return await operation();
|
|
323
|
+
} catch (error) {
|
|
324
|
+
if (!isRetryableEndpointError(error)) throw error;
|
|
325
|
+
|
|
326
|
+
const remainingMs = deadline - Date.now();
|
|
327
|
+
if (remainingMs <= 0) throw error;
|
|
328
|
+
|
|
329
|
+
await sleep(Math.min(DEFAULT_ENDPOINT_RETRY_MS, remainingMs));
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function isRetryableEndpointError(error: unknown) {
|
|
335
|
+
return error instanceof DevToolsEndpointError && error.retryable;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function endpointConnectionErrorMessage(error: unknown) {
|
|
339
|
+
const reason = isTimeoutError(error) ? "request timed out" : "connection failed";
|
|
340
|
+
return [
|
|
341
|
+
`Cannot connect to Chrome DevTools endpoint at ${devToolsEndpoint()} (${reason}).`,
|
|
342
|
+
launchHint(),
|
|
343
|
+
endpointConfigHint(),
|
|
344
|
+
].join("\n");
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function isTimeoutError(error: unknown) {
|
|
348
|
+
return error instanceof Error && ["AbortError", "TimeoutError"].includes(error.name);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function devToolsEndpoint() {
|
|
352
|
+
return `http://${state.host}:${state.port}`;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function launchHint() {
|
|
356
|
+
return `Start Chrome with remote debugging enabled: ${chromeLaunchCommand()}`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function chromeLaunchCommand() {
|
|
360
|
+
const executable =
|
|
361
|
+
process.platform === "darwin"
|
|
362
|
+
? "/Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome"
|
|
363
|
+
: process.platform === "win32"
|
|
364
|
+
? "chrome.exe"
|
|
365
|
+
: "google-chrome";
|
|
366
|
+
const dataDir = process.platform === "win32" ? "%TEMP%\\pi-chrome-devtools" : "/tmp/pi-chrome-devtools";
|
|
367
|
+
return `${executable} --remote-debugging-port=${state.port} --user-data-dir=${dataDir}`;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function endpointConfigHint() {
|
|
371
|
+
return "Set PI_CHROME_DEVTOOLS_HOST and PI_CHROME_DEVTOOLS_PORT if Chrome uses a different endpoint.";
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function formatPageListItem(page: DevToolsPage) {
|
|
375
|
+
return `- ${page.id}: ${page.title || "(untitled)"} ${page.url}`;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function sleep(ms: number) {
|
|
379
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
class DevToolsEndpointError extends Error {
|
|
383
|
+
readonly retryable: boolean;
|
|
384
|
+
|
|
385
|
+
constructor(message: string, options: { retryable?: boolean } = {}) {
|
|
386
|
+
super(message);
|
|
387
|
+
this.name = "DevToolsEndpointError";
|
|
388
|
+
this.retryable = options.retryable ?? false;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
241
392
|
function formatPage(page: DevToolsPage) {
|
|
242
393
|
return {
|
|
243
394
|
id: page.id,
|
|
@@ -284,8 +435,10 @@ class CdpClient {
|
|
|
284
435
|
timeout: NodeJS.Timeout;
|
|
285
436
|
}
|
|
286
437
|
>();
|
|
438
|
+
private readonly socket: WebSocket;
|
|
287
439
|
|
|
288
|
-
private constructor(
|
|
440
|
+
private constructor(socket: WebSocket) {
|
|
441
|
+
this.socket = socket;
|
|
289
442
|
socket.addEventListener("message", (event) => {
|
|
290
443
|
const response = JSON.parse(String(event.data)) as CdpResponse;
|
|
291
444
|
if (typeof response.id !== "number") return;
|