cdp-toolkit 1.0.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/CHANGELOG.md +29 -0
- package/CONTRACT.md +116 -0
- package/LICENSE +21 -0
- package/README.md +255 -0
- package/dist/cli.js +2108 -0
- package/dist/index.js +2019 -0
- package/dist/mcp.js +3056 -0
- package/package.json +82 -0
- package/src/cli.ts +145 -0
- package/src/client.ts +255 -0
- package/src/index.ts +128 -0
- package/src/manifest.ts +1030 -0
- package/src/mcp.ts +61 -0
- package/src/tools/console.ts +199 -0
- package/src/tools/dialogs.ts +236 -0
- package/src/tools/emulation.ts +212 -0
- package/src/tools/evaluate.ts +155 -0
- package/src/tools/heap.ts +109 -0
- package/src/tools/input.ts +399 -0
- package/src/tools/lighthouse.ts +285 -0
- package/src/tools/navigation.ts +202 -0
- package/src/tools/network.ts +275 -0
- package/src/tools/network_mock.ts +367 -0
- package/src/tools/pages.ts +159 -0
- package/src/tools/performance.ts +492 -0
- package/src/tools/recorder.ts +265 -0
- package/src/tools/screenshot.ts +180 -0
- package/src/tools/snapshot.ts +206 -0
- package/src/types.ts +63 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Navigation + wait tools (raw CDP).
|
|
3
|
+
*
|
|
4
|
+
* Replicates chrome-devtools-mcp's `navigate_page` and `wait_for` over a direct
|
|
5
|
+
* WebSocket, built only on the core client (`withPage`). No Puppeteer / MCP.
|
|
6
|
+
*
|
|
7
|
+
* - navigate_page: Page.enable -> Page.navigate, then await the load milestone
|
|
8
|
+
* (Page.loadEventFired for 'load'; Page.domContentEventFired /
|
|
9
|
+
* Page.frameStoppedLoading for 'domcontentloaded') with a bounded timeout so a
|
|
10
|
+
* wedged renderer can't hang the caller.
|
|
11
|
+
* - wait_for: polls Runtime.evaluate(document.body.innerText.includes(text))
|
|
12
|
+
* on a fixed interval until the text appears or the timeout elapses.
|
|
13
|
+
*/
|
|
14
|
+
import { CdpError, withPage } from "../client.ts";
|
|
15
|
+
import type { CdpConnection } from "../client.ts";
|
|
16
|
+
import type { Target, TargetSelector } from "../types.ts";
|
|
17
|
+
|
|
18
|
+
export interface NavigatePageArgs {
|
|
19
|
+
/** Page selector; defaults to the active page. */
|
|
20
|
+
target?: TargetSelector;
|
|
21
|
+
/** Destination URL. Required unless `reload` is true. */
|
|
22
|
+
url?: string;
|
|
23
|
+
/** Reload the current page (Page.reload) instead of navigating to `url`. */
|
|
24
|
+
reload?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* When reloading, bypass the HTTP cache (hard reload) so freshly-deployed,
|
|
27
|
+
* non-content-hashed bundles are refetched instead of served stale. Ignored
|
|
28
|
+
* unless `reload` is true.
|
|
29
|
+
*/
|
|
30
|
+
ignoreCache?: boolean;
|
|
31
|
+
/** Which load milestone to wait for. Defaults to 'load'. */
|
|
32
|
+
waitUntil?: "load" | "domcontentloaded";
|
|
33
|
+
/** Override the navigation timeout (ms). */
|
|
34
|
+
timeoutMs?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface NavigatePageResult {
|
|
38
|
+
url: string;
|
|
39
|
+
frameId: string;
|
|
40
|
+
/** True when the result came from a reload rather than a fresh navigation. */
|
|
41
|
+
reloaded?: boolean;
|
|
42
|
+
/** The milestone that resolved the wait, or 'timeout' if navigation committed but the milestone never fired in time. */
|
|
43
|
+
waitedFor: "load" | "domcontentloaded" | "frameStoppedLoading" | "navigate-only";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Navigate a page to `url`, OR reload it (`reload:true`), and wait for it to
|
|
48
|
+
* finish loading.
|
|
49
|
+
*
|
|
50
|
+
* `Page.navigate`/`Page.reload` resolve once the action is *committed*. We
|
|
51
|
+
* separately race the load milestone event — subscribed BEFORE issuing the
|
|
52
|
+
* command so a fast load can't fire before we're listening. Pass
|
|
53
|
+
* `reload:true, ignoreCache:true` for a hard reload that refetches every
|
|
54
|
+
* subresource (the only way to pick up a freshly-deployed bundle the HTTP
|
|
55
|
+
* cache would otherwise serve stale).
|
|
56
|
+
*/
|
|
57
|
+
export async function navigatePage(args: NavigatePageArgs): Promise<NavigatePageResult> {
|
|
58
|
+
const reload = args.reload === true;
|
|
59
|
+
if (!reload && (!args.url || typeof args.url !== "string")) {
|
|
60
|
+
throw new CdpError("navigate_page: 'url' is required (or pass reload:true to reload the current page)");
|
|
61
|
+
}
|
|
62
|
+
const waitUntil = args.waitUntil ?? "load";
|
|
63
|
+
|
|
64
|
+
return withPage(
|
|
65
|
+
args.target,
|
|
66
|
+
async (conn: CdpConnection): Promise<NavigatePageResult> => {
|
|
67
|
+
await conn.send("Page.enable");
|
|
68
|
+
|
|
69
|
+
// Subscribe to the load milestone BEFORE acting so a fast page that loads
|
|
70
|
+
// before the navigate/reload command resolves still satisfies the wait.
|
|
71
|
+
const milestoneMethod = waitUntil === "load" ? "Page.loadEventFired" : "Page.domContentEventFired";
|
|
72
|
+
const milestone = conn
|
|
73
|
+
.waitFor(milestoneMethod, undefined, args.timeoutMs)
|
|
74
|
+
.then(() => waitUntil)
|
|
75
|
+
.catch(() => undefined);
|
|
76
|
+
// Secondary fallback: frameStoppedLoading covers same-document and edge
|
|
77
|
+
// cases where the primary load event is suppressed.
|
|
78
|
+
const stopped = conn
|
|
79
|
+
.waitFor<{ frameId?: string }>("Page.frameStoppedLoading", undefined, args.timeoutMs)
|
|
80
|
+
.then(() => "frameStoppedLoading" as const)
|
|
81
|
+
.catch(() => undefined);
|
|
82
|
+
|
|
83
|
+
let resolvedUrl: string;
|
|
84
|
+
let frameId: string;
|
|
85
|
+
if (reload) {
|
|
86
|
+
// Page.reload has no return payload; ignoreCache:true forces a network
|
|
87
|
+
// refetch of every subresource (a true hard reload).
|
|
88
|
+
await conn.send(
|
|
89
|
+
"Page.reload",
|
|
90
|
+
{ ignoreCache: args.ignoreCache === true },
|
|
91
|
+
args.timeoutMs ? { timeoutMs: args.timeoutMs } : {},
|
|
92
|
+
);
|
|
93
|
+
// Recover the main frame id + post-reload URL (reload keeps the same URL).
|
|
94
|
+
const tree = await conn
|
|
95
|
+
.send<{ frameTree?: { frame?: { id?: string; url?: string } } }>("Page.getFrameTree")
|
|
96
|
+
.catch(() => undefined);
|
|
97
|
+
frameId = tree?.frameTree?.frame?.id ?? "";
|
|
98
|
+
resolvedUrl = tree?.frameTree?.frame?.url ?? args.url ?? "";
|
|
99
|
+
} else {
|
|
100
|
+
const nav = await conn.send<{ frameId: string; loaderId?: string; errorText?: string }>(
|
|
101
|
+
"Page.navigate",
|
|
102
|
+
{ url: args.url },
|
|
103
|
+
args.timeoutMs ? { timeoutMs: args.timeoutMs } : {},
|
|
104
|
+
);
|
|
105
|
+
if (nav.errorText) {
|
|
106
|
+
throw new CdpError(`navigate_page: ${nav.errorText} (${args.url})`);
|
|
107
|
+
}
|
|
108
|
+
frameId = nav.frameId;
|
|
109
|
+
resolvedUrl = args.url as string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const settled = await Promise.race([milestone, stopped]);
|
|
113
|
+
const waitedFor: NavigatePageResult["waitedFor"] = settled ?? "navigate-only";
|
|
114
|
+
|
|
115
|
+
return reload
|
|
116
|
+
? { url: resolvedUrl, frameId, reloaded: true, waitedFor }
|
|
117
|
+
: { url: resolvedUrl, frameId, waitedFor };
|
|
118
|
+
},
|
|
119
|
+
args.timeoutMs ? { timeoutMs: args.timeoutMs } : {},
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface WaitForArgs {
|
|
124
|
+
/** Page selector; defaults to the active page. */
|
|
125
|
+
target?: TargetSelector;
|
|
126
|
+
/** Substring to wait for in document.body.innerText. Required. */
|
|
127
|
+
text: string;
|
|
128
|
+
/** Total time budget (ms). Defaults to 15000. */
|
|
129
|
+
timeoutMs?: number;
|
|
130
|
+
/** Poll interval (ms). Defaults to 250. */
|
|
131
|
+
pollMs?: number;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface WaitForResult {
|
|
135
|
+
found: true;
|
|
136
|
+
waitedMs: number;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const DEFAULT_WAIT_TIMEOUT_MS = 15_000;
|
|
140
|
+
const DEFAULT_POLL_MS = 250;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Poll the page until `text` appears in document.body.innerText, or throw on
|
|
144
|
+
* timeout. Polling (rather than a DOM-mutation subscription) matches the MCP
|
|
145
|
+
* tool's semantics and is robust to text that's present on first paint.
|
|
146
|
+
*/
|
|
147
|
+
export async function waitForText(args: WaitForArgs): Promise<WaitForResult> {
|
|
148
|
+
if (typeof args.text !== "string" || args.text.length === 0) {
|
|
149
|
+
throw new CdpError("wait_for: 'text' is required");
|
|
150
|
+
}
|
|
151
|
+
const timeoutMs = args.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS;
|
|
152
|
+
const pollMs = args.pollMs ?? DEFAULT_POLL_MS;
|
|
153
|
+
const start = Date.now();
|
|
154
|
+
|
|
155
|
+
return withPage(
|
|
156
|
+
args.target,
|
|
157
|
+
async (conn: CdpConnection, _target: Target): Promise<WaitForResult> => {
|
|
158
|
+
await conn.send("Runtime.enable");
|
|
159
|
+
const expr = `(() => { const b = document.body; return !!b && typeof b.innerText === 'string' && b.innerText.includes(${JSON.stringify(args.text)}); })()`;
|
|
160
|
+
|
|
161
|
+
for (;;) {
|
|
162
|
+
const { result } = await conn.send<{ result: { value?: unknown } }>("Runtime.evaluate", {
|
|
163
|
+
expression: expr,
|
|
164
|
+
returnByValue: true,
|
|
165
|
+
});
|
|
166
|
+
if (result?.value === true) {
|
|
167
|
+
return { found: true, waitedMs: Date.now() - start };
|
|
168
|
+
}
|
|
169
|
+
if (Date.now() - start >= timeoutMs) {
|
|
170
|
+
throw new CdpError(`wait_for: text not found within ${timeoutMs}ms: ${JSON.stringify(args.text)}`);
|
|
171
|
+
}
|
|
172
|
+
await delay(Math.min(pollMs, Math.max(0, timeoutMs - (Date.now() - start))));
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
// Give the per-command timeout enough headroom for the whole poll budget.
|
|
176
|
+
{ timeoutMs: Math.max(timeoutMs, DEFAULT_WAIT_TIMEOUT_MS) },
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Contract-mandated alias: `wait_for` -> `waitForText`, also exported as `waitFor`. */
|
|
181
|
+
export const waitFor = waitForText;
|
|
182
|
+
|
|
183
|
+
function delay(ms: number): Promise<void> {
|
|
184
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/*
|
|
188
|
+
* CDP methods/domains used:
|
|
189
|
+
* - Page.enable
|
|
190
|
+
* - Page.navigate
|
|
191
|
+
* - Page.reload (reload:true; ignoreCache:true = hard reload)
|
|
192
|
+
* - Page.getFrameTree (recover main frameId + URL after a reload)
|
|
193
|
+
* - Page.loadEventFired (event; waitUntil:'load')
|
|
194
|
+
* - Page.domContentEventFired (event; waitUntil:'domcontentloaded')
|
|
195
|
+
* - Page.frameStoppedLoading (event; fallback load milestone)
|
|
196
|
+
* - Runtime.enable
|
|
197
|
+
* - Runtime.evaluate (poll document.body.innerText)
|
|
198
|
+
*
|
|
199
|
+
* Parity gaps vs chrome-devtools-mcp:
|
|
200
|
+
* - navigate_page: no automatic "snapshot of the new page" return; we return {url,frameId,waitedFor}. waitUntil supports 'load'|'domcontentloaded' only (no 'networkidle'). reload:true (+ignoreCache for a hard reload) covers the MCP's reload navigation type.
|
|
201
|
+
* - wait_for: only text-substring waiting (innerText.includes); no aria/role/selector or "wait for event" variants. Throws on timeout rather than returning {found:false}.
|
|
202
|
+
*/
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* network.ts — read network activity captured by the recorder.
|
|
3
|
+
*
|
|
4
|
+
* list_network_requests -> listNetworkRequests
|
|
5
|
+
* get_network_request -> getNetworkRequest
|
|
6
|
+
*
|
|
7
|
+
* READ vs CAPTURE model (see recorder.ts header):
|
|
8
|
+
* - default ({ reload: false }): read the target's shared "latest" buffer
|
|
9
|
+
* (rec-<targetId>.jsonl), correlate the four Network.* events by requestId,
|
|
10
|
+
* and return one row per request.
|
|
11
|
+
* - { reload: true }: run a captureWindow (both domains) — Page.reload, capture
|
|
12
|
+
* for `durationMs` (default 2500ms), stop, then read+return the requests.
|
|
13
|
+
*
|
|
14
|
+
* Response bodies: CDP only serves a body from the LIVE renderer session, so
|
|
15
|
+
* get_network_request with { includeBody: true } drives a fresh reload capture
|
|
16
|
+
* and calls Network.getResponseBody before closing. Because a reload re-mints
|
|
17
|
+
* requestIds, body fetch is matched by `url` (stable), not by a carried-over
|
|
18
|
+
* requestId — see getNetworkRequest for the exact contract.
|
|
19
|
+
*/
|
|
20
|
+
import { readFile } from "node:fs/promises";
|
|
21
|
+
import { CdpError, resolveTarget } from "../client.ts";
|
|
22
|
+
import type { TargetSelector } from "../types.ts";
|
|
23
|
+
import { recFile, captureWindow } from "./recorder.ts";
|
|
24
|
+
import type { RecLine } from "./recorder.ts";
|
|
25
|
+
|
|
26
|
+
const DEFAULT_CAPTURE_MS = 2500;
|
|
27
|
+
|
|
28
|
+
/** A correlated network request row. */
|
|
29
|
+
export interface NetworkRequest {
|
|
30
|
+
requestId: string;
|
|
31
|
+
url: string;
|
|
32
|
+
method: string;
|
|
33
|
+
resourceType?: string;
|
|
34
|
+
/** HTTP status code from Network.responseReceived (undefined if pending/failed). */
|
|
35
|
+
status?: number;
|
|
36
|
+
statusText?: string;
|
|
37
|
+
mimeType?: string;
|
|
38
|
+
/** Response headers (lower-cased keys as CDP delivers them). */
|
|
39
|
+
responseHeaders?: Record<string, string>;
|
|
40
|
+
/** Request headers as sent. */
|
|
41
|
+
requestHeaders?: Record<string, string>;
|
|
42
|
+
/** Total encoded bytes received, when loadingFinished reported it. */
|
|
43
|
+
encodedDataLength?: number;
|
|
44
|
+
/** "finished" | "failed" | "pending". */
|
|
45
|
+
state: "finished" | "failed" | "pending";
|
|
46
|
+
/** Failure text from Network.loadingFailed, when state === "failed". */
|
|
47
|
+
errorText?: string;
|
|
48
|
+
/** Capture time of the initiating requestWillBeSent (ms since epoch). */
|
|
49
|
+
ts: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ListNetworkRequestsArgs {
|
|
53
|
+
target?: TargetSelector;
|
|
54
|
+
reload?: boolean;
|
|
55
|
+
durationMs?: number;
|
|
56
|
+
/** Only return requests whose URL contains this substring. */
|
|
57
|
+
filterUrl?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface GetNetworkRequestArgs {
|
|
61
|
+
target?: TargetSelector;
|
|
62
|
+
/** Match by exact requestId (metadata only — see includeBody). */
|
|
63
|
+
requestId?: string;
|
|
64
|
+
/** Match by URL substring (first match). Required for body fetch (includeBody). */
|
|
65
|
+
url?: string;
|
|
66
|
+
/** Fetch the response body too (drives a fresh reload capture; use with `url`). */
|
|
67
|
+
includeBody?: boolean;
|
|
68
|
+
durationMs?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Coerce a CDP headers object to a string-keyed/string-valued record. */
|
|
72
|
+
function asHeaders(v: unknown): Record<string, string> | undefined {
|
|
73
|
+
if (!v || typeof v !== "object") return undefined;
|
|
74
|
+
const out: Record<string, string> = {};
|
|
75
|
+
for (const [k, val] of Object.entries(v as Record<string, unknown>)) out[k] = String(val);
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Correlate the four Network.* events from a JSONL buffer into request rows. */
|
|
80
|
+
function correlate(lines: RecLine[]): NetworkRequest[] {
|
|
81
|
+
const byId = new Map<string, NetworkRequest>();
|
|
82
|
+
const order: string[] = [];
|
|
83
|
+
|
|
84
|
+
for (const line of lines) {
|
|
85
|
+
if (line.kind !== "network") continue;
|
|
86
|
+
const p = line.params;
|
|
87
|
+
const requestId = typeof p.requestId === "string" ? p.requestId : undefined;
|
|
88
|
+
if (!requestId) continue;
|
|
89
|
+
|
|
90
|
+
if (line.method === "Network.requestWillBeSent") {
|
|
91
|
+
const req = (p.request ?? {}) as Record<string, unknown>;
|
|
92
|
+
if (!byId.has(requestId)) order.push(requestId);
|
|
93
|
+
// requestWillBeSent can fire twice (redirects); keep the first, but always
|
|
94
|
+
// ensure a row exists.
|
|
95
|
+
const existing = byId.get(requestId);
|
|
96
|
+
const row: NetworkRequest = existing ?? {
|
|
97
|
+
requestId,
|
|
98
|
+
url: typeof req.url === "string" ? req.url : "",
|
|
99
|
+
method: typeof req.method === "string" ? req.method : "GET",
|
|
100
|
+
resourceType: typeof p.type === "string" ? p.type : undefined,
|
|
101
|
+
requestHeaders: asHeaders(req.headers),
|
|
102
|
+
state: "pending",
|
|
103
|
+
ts: line.ts,
|
|
104
|
+
};
|
|
105
|
+
byId.set(requestId, row);
|
|
106
|
+
} else if (line.method === "Network.responseReceived") {
|
|
107
|
+
const resp = (p.response ?? {}) as Record<string, unknown>;
|
|
108
|
+
const row = byId.get(requestId);
|
|
109
|
+
if (row) {
|
|
110
|
+
row.status = typeof resp.status === "number" ? resp.status : row.status;
|
|
111
|
+
row.statusText = typeof resp.statusText === "string" ? resp.statusText : row.statusText;
|
|
112
|
+
row.mimeType = typeof resp.mimeType === "string" ? resp.mimeType : row.mimeType;
|
|
113
|
+
row.responseHeaders = asHeaders(resp.headers) ?? row.responseHeaders;
|
|
114
|
+
if (typeof p.type === "string") row.resourceType = p.type;
|
|
115
|
+
}
|
|
116
|
+
} else if (line.method === "Network.loadingFinished") {
|
|
117
|
+
const row = byId.get(requestId);
|
|
118
|
+
if (row) {
|
|
119
|
+
row.state = "finished";
|
|
120
|
+
if (typeof p.encodedDataLength === "number") row.encodedDataLength = p.encodedDataLength;
|
|
121
|
+
}
|
|
122
|
+
} else if (line.method === "Network.loadingFailed") {
|
|
123
|
+
const row = byId.get(requestId);
|
|
124
|
+
if (row) {
|
|
125
|
+
row.state = "failed";
|
|
126
|
+
if (typeof p.errorText === "string") row.errorText = p.errorText;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return order.map((id) => byId.get(id)!).filter(Boolean);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Parse a JSONL buffer file into correlated network request rows. */
|
|
135
|
+
async function readNetworkRequests(file: string): Promise<NetworkRequest[]> {
|
|
136
|
+
let raw: string;
|
|
137
|
+
try {
|
|
138
|
+
raw = await readFile(file, "utf8");
|
|
139
|
+
} catch {
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
const lines: RecLine[] = [];
|
|
143
|
+
for (const rawLine of raw.split("\n")) {
|
|
144
|
+
const trimmed = rawLine.trim();
|
|
145
|
+
if (!trimmed) continue;
|
|
146
|
+
try {
|
|
147
|
+
lines.push(JSON.parse(trimmed) as RecLine);
|
|
148
|
+
} catch {
|
|
149
|
+
/* skip malformed line */
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return correlate(lines);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* list_network_requests — correlated request rows for the target. With
|
|
157
|
+
* `reload:true`, records a fresh capture window (both domains) by reloading.
|
|
158
|
+
*/
|
|
159
|
+
export async function listNetworkRequests(args: ListNetworkRequestsArgs = {}): Promise<{
|
|
160
|
+
target: { id: string; url: string; title: string };
|
|
161
|
+
count: number;
|
|
162
|
+
requests: NetworkRequest[];
|
|
163
|
+
droppedWrites?: number;
|
|
164
|
+
}> {
|
|
165
|
+
let file: string;
|
|
166
|
+
let resolved: { id: string; url: string; title: string };
|
|
167
|
+
let droppedWrites: number | undefined;
|
|
168
|
+
|
|
169
|
+
if (args.reload) {
|
|
170
|
+
const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS);
|
|
171
|
+
await cap.stop();
|
|
172
|
+
file = cap.file;
|
|
173
|
+
resolved = cap.resolved;
|
|
174
|
+
droppedWrites = cap.droppedWrites();
|
|
175
|
+
} else {
|
|
176
|
+
const target = await resolveTarget(args.target);
|
|
177
|
+
file = recFile(target.id);
|
|
178
|
+
resolved = { id: target.id, url: target.url, title: target.title };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let requests = await readNetworkRequests(file);
|
|
182
|
+
if (args.filterUrl) {
|
|
183
|
+
const needle = args.filterUrl;
|
|
184
|
+
requests = requests.filter((r) => r.url.includes(needle));
|
|
185
|
+
}
|
|
186
|
+
return { target: resolved, count: requests.length, requests, droppedWrites };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* get_network_request — return one request (matched by requestId, else by URL
|
|
191
|
+
* substring) including response status/headers.
|
|
192
|
+
*
|
|
193
|
+
* Body retrieval: CDP only serves a response body from the LIVE renderer
|
|
194
|
+
* session, so `includeBody:true` must drive a fresh reload capture and fetch the
|
|
195
|
+
* body before the connection closes. A reload re-mints requestIds, so a body
|
|
196
|
+
* fetch can only be matched by `url` (stable across reload) — NOT by a
|
|
197
|
+
* `requestId` carried over from a prior `list_network_requests`. When a
|
|
198
|
+
* requestId is supplied with includeBody we therefore return metadata from the
|
|
199
|
+
* existing buffer and explain why the body is unavailable, rather than reloading
|
|
200
|
+
* into a guaranteed no-match.
|
|
201
|
+
*/
|
|
202
|
+
export async function getNetworkRequest(args: GetNetworkRequestArgs = {}): Promise<
|
|
203
|
+
NetworkRequest & { body?: string; bodyBase64Encoded?: boolean; bodyUnavailableReason?: string }
|
|
204
|
+
> {
|
|
205
|
+
if (!args.requestId && !args.url) {
|
|
206
|
+
throw new CdpError("get_network_request requires either requestId or url");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const pick = (requests: NetworkRequest[]): NetworkRequest | undefined => {
|
|
210
|
+
if (args.requestId) return requests.find((r) => r.requestId === args.requestId);
|
|
211
|
+
return requests.find((r) => r.url.includes(args.url!));
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const describeSelector = (): string =>
|
|
215
|
+
args.requestId ? `requestId ${args.requestId}` : `url ~ '${args.url}'`;
|
|
216
|
+
|
|
217
|
+
// Body fetch by URL: a fresh reload capture (url is stable across reload).
|
|
218
|
+
if (args.includeBody && args.url) {
|
|
219
|
+
const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS);
|
|
220
|
+
try {
|
|
221
|
+
await cap.flush(); // ensure the buffer is complete; conn stays open for the body fetch
|
|
222
|
+
const match = (await readNetworkRequests(cap.file)).find((r) => r.url.includes(args.url!));
|
|
223
|
+
if (!match) {
|
|
224
|
+
throw new CdpError(`no network request matched ${describeSelector()} in fresh capture`);
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
const { body, base64Encoded } = await cap.conn.send<{ body: string; base64Encoded: boolean }>(
|
|
228
|
+
"Network.getResponseBody",
|
|
229
|
+
{ requestId: match.requestId },
|
|
230
|
+
);
|
|
231
|
+
return { ...match, body, bodyBase64Encoded: base64Encoded };
|
|
232
|
+
} catch {
|
|
233
|
+
// No body (e.g. redirect/204/failed) or already evicted.
|
|
234
|
+
return { ...match, body: undefined, bodyBase64Encoded: undefined };
|
|
235
|
+
}
|
|
236
|
+
} finally {
|
|
237
|
+
await cap.stop();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Metadata path: read the target's existing buffer (also the requestId+includeBody case).
|
|
242
|
+
const target = await resolveTarget(args.target);
|
|
243
|
+
const match = pick(await readNetworkRequests(recFile(target.id)));
|
|
244
|
+
if (!match) {
|
|
245
|
+
throw new CdpError(
|
|
246
|
+
`no network request matched ${describeSelector()}; run list_network_requests with reload:true first` +
|
|
247
|
+
(args.includeBody ? `, then fetch the body with the url selector (reload re-mints requestIds)` : ``),
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
if (args.includeBody && args.requestId) {
|
|
251
|
+
return {
|
|
252
|
+
...match,
|
|
253
|
+
body: undefined,
|
|
254
|
+
bodyUnavailableReason:
|
|
255
|
+
"response bodies are only fetchable via a fresh reload capture, which re-mints requestIds; re-request with the `url` selector to retrieve the body",
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
return match;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/*
|
|
262
|
+
* CDP methods / domains used (via recorder.ts + this module):
|
|
263
|
+
* Network.enable (recorder)
|
|
264
|
+
* Network.requestWillBeSent / .responseReceived / .loadingFinished /
|
|
265
|
+
* .loadingFailed (events) (recorder)
|
|
266
|
+
* Network.getResponseBody (includeBody mode)
|
|
267
|
+
* Page.enable / Page.reload (reload mode)
|
|
268
|
+
*
|
|
269
|
+
* Parity gaps vs chrome-devtools-mcp:
|
|
270
|
+
* - Response bodies require a fresh reload capture (CDP serves bodies only from
|
|
271
|
+
* the live session); reading the on-disk buffer yields metadata only.
|
|
272
|
+
* - Redirect chains collapse to the original requestId's first row; per-hop
|
|
273
|
+
* redirect entries are not expanded.
|
|
274
|
+
* - Timing breakdown (DNS/connect/TTFB) and request POST data are not surfaced.
|
|
275
|
+
*/
|