cdp-toolkit 1.1.0 → 1.4.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 +203 -9
- package/CONTRACT.md +7 -5
- package/README.md +113 -33
- package/dist/cli.js +764 -64
- package/dist/index.js +650 -42
- package/dist/mcp.js +1091 -77
- package/package.json +4 -3
- package/src/bidi/driver.ts +167 -4
- package/src/capabilities.ts +1 -1
- package/src/cdp/driver.ts +145 -1
- package/src/cli.ts +47 -22
- package/src/client.ts +25 -7
- package/src/driver.ts +107 -1
- package/src/firefox-tools.ts +6 -3
- package/src/index.ts +14 -2
- package/src/leases-tools.ts +107 -0
- package/src/leases.ts +541 -0
- package/src/manifest.ts +339 -15
- package/src/mcp.ts +27 -19
- package/src/origins.ts +266 -0
- package/src/shared-tools.ts +365 -14
- package/src/tools/console.ts +3 -3
- package/src/tools/evaluate.ts +2 -2
- package/src/tools/heap.ts +3 -3
- package/src/tools/lighthouse.ts +4 -4
- package/src/tools/navigation.ts +1 -1
- package/src/tools/network.ts +7 -7
- package/src/tools/network_mock.ts +8 -8
- package/src/tools/performance.ts +11 -11
- package/src/tools/recorder.ts +5 -5
- package/src/tools/snapshot.ts +2 -2
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,260 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
// src/leases.ts
|
|
4
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import { mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
var DEFAULT_LEASE_TTL_MS = 900000;
|
|
9
|
+
function leaseDir() {
|
|
10
|
+
return process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
11
|
+
}
|
|
12
|
+
function leaseTtlMs() {
|
|
13
|
+
const raw = Number(process.env.CDP_LEASE_TTL_MS);
|
|
14
|
+
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_LEASE_TTL_MS;
|
|
15
|
+
}
|
|
16
|
+
function defaultLabel() {
|
|
17
|
+
return `pid-${process.pid}`;
|
|
18
|
+
}
|
|
19
|
+
function safeId(id) {
|
|
20
|
+
return id.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
21
|
+
}
|
|
22
|
+
function leaseFile(backend, targetId) {
|
|
23
|
+
return join(leaseDir(), `lease-${backend}-${safeId(targetId)}.json`);
|
|
24
|
+
}
|
|
25
|
+
function mintToken(backend, targetId, nonce) {
|
|
26
|
+
return `${backend}:${targetId}:${nonce}`;
|
|
27
|
+
}
|
|
28
|
+
function tokenParts(token) {
|
|
29
|
+
if (typeof token !== "string")
|
|
30
|
+
return;
|
|
31
|
+
const first = token.indexOf(":");
|
|
32
|
+
const last = token.lastIndexOf(":");
|
|
33
|
+
if (first <= 0 || last <= first)
|
|
34
|
+
return;
|
|
35
|
+
const backend = token.slice(0, first);
|
|
36
|
+
if (backend !== "chrome" && backend !== "firefox")
|
|
37
|
+
return;
|
|
38
|
+
const targetId = token.slice(first + 1, last);
|
|
39
|
+
const nonce = token.slice(last + 1);
|
|
40
|
+
if (!targetId.length || !/^[0-9a-f]{24}$/.test(nonce))
|
|
41
|
+
return;
|
|
42
|
+
return { backend, targetId, nonce };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
class LeaseConflictError extends Error {
|
|
46
|
+
targetId;
|
|
47
|
+
holder;
|
|
48
|
+
constructor(message, targetId, holder) {
|
|
49
|
+
super(message);
|
|
50
|
+
this.targetId = targetId;
|
|
51
|
+
this.holder = holder;
|
|
52
|
+
this.name = "LeaseConflictError";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async function readLease(backend, targetId) {
|
|
56
|
+
let raw;
|
|
57
|
+
try {
|
|
58
|
+
raw = await readFile(leaseFile(backend, targetId), "utf8");
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (err.code === "ENOENT")
|
|
61
|
+
return;
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const rec = JSON.parse(raw);
|
|
66
|
+
if (typeof rec?.nonce === "string" && typeof rec?.pid === "number")
|
|
67
|
+
return rec;
|
|
68
|
+
} catch {}
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
function isPidAlive(pid) {
|
|
72
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
73
|
+
return false;
|
|
74
|
+
try {
|
|
75
|
+
process.kill(pid, 0);
|
|
76
|
+
return true;
|
|
77
|
+
} catch (err) {
|
|
78
|
+
return err.code === "EPERM";
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function staleReason(rec, opts) {
|
|
82
|
+
if (!isPidAlive(rec.pid))
|
|
83
|
+
return "dead-pid";
|
|
84
|
+
if (opts.now - rec.lastUsedAt > rec.ttlMs)
|
|
85
|
+
return "expired";
|
|
86
|
+
if (opts.liveIds && !opts.liveIds.includes(rec.targetId))
|
|
87
|
+
return "target-gone";
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
async function claimLease(backend, targetId, opts) {
|
|
91
|
+
const now = opts.now ?? Date.now();
|
|
92
|
+
await mkdir(leaseDir(), { recursive: true });
|
|
93
|
+
for (let attempt = 0;attempt < 3; attempt++) {
|
|
94
|
+
const record = {
|
|
95
|
+
backend,
|
|
96
|
+
targetId,
|
|
97
|
+
nonce: randomBytes(12).toString("hex"),
|
|
98
|
+
pid: process.pid,
|
|
99
|
+
label: opts.label,
|
|
100
|
+
createdAt: now,
|
|
101
|
+
lastUsedAt: now,
|
|
102
|
+
ttlMs: opts.ttlMs ?? leaseTtlMs()
|
|
103
|
+
};
|
|
104
|
+
try {
|
|
105
|
+
await writeFile(leaseFile(backend, targetId), JSON.stringify(record), { flag: "wx" });
|
|
106
|
+
return { token: mintToken(backend, targetId, record.nonce), record };
|
|
107
|
+
} catch (err) {
|
|
108
|
+
if (err.code !== "EEXIST")
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
const existing = await readLease(backend, targetId);
|
|
112
|
+
if (!existing)
|
|
113
|
+
continue;
|
|
114
|
+
const stale = staleReason(existing, { now, liveIds: opts.liveIds });
|
|
115
|
+
if (!stale) {
|
|
116
|
+
throw new LeaseConflictError(`target '${targetId}' is already leased by '${existing.label}' (pid ${existing.pid}); call release_page with that lease's token, or list_leases to inspect it`, targetId, existing.label);
|
|
117
|
+
}
|
|
118
|
+
await unlink(leaseFile(backend, targetId)).catch(() => {
|
|
119
|
+
return;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
throw new LeaseConflictError(`target '${targetId}' could not be claimed after 3 attempts: another process is claiming and releasing it concurrently`, targetId);
|
|
123
|
+
}
|
|
124
|
+
async function releaseLease(token) {
|
|
125
|
+
const parts = tokenParts(token);
|
|
126
|
+
if (!parts)
|
|
127
|
+
return { released: false };
|
|
128
|
+
const rec = await readLease(parts.backend, parts.targetId);
|
|
129
|
+
if (!rec || rec.nonce !== parts.nonce)
|
|
130
|
+
return { released: false };
|
|
131
|
+
await unlink(leaseFile(parts.backend, parts.targetId)).catch(() => {
|
|
132
|
+
return;
|
|
133
|
+
});
|
|
134
|
+
return { released: true };
|
|
135
|
+
}
|
|
136
|
+
async function releaseLeaseFor(backend, targetId) {
|
|
137
|
+
if (!await readLease(backend, targetId))
|
|
138
|
+
return { released: false };
|
|
139
|
+
await unlink(leaseFile(backend, targetId)).catch(() => {
|
|
140
|
+
return;
|
|
141
|
+
});
|
|
142
|
+
return { released: true };
|
|
143
|
+
}
|
|
144
|
+
var leaseScope = new AsyncLocalStorage;
|
|
145
|
+
function withLeaseScope(lease, fn) {
|
|
146
|
+
return leaseScope.run(lease, fn);
|
|
147
|
+
}
|
|
148
|
+
function currentLease() {
|
|
149
|
+
return leaseScope.getStore();
|
|
150
|
+
}
|
|
151
|
+
function leaseFromArgs(args) {
|
|
152
|
+
if (!args || typeof args !== "object")
|
|
153
|
+
return;
|
|
154
|
+
const lease = args.lease;
|
|
155
|
+
return typeof lease === "string" && lease.length > 0 ? lease : undefined;
|
|
156
|
+
}
|
|
157
|
+
async function touchLease(rec, now = Date.now()) {
|
|
158
|
+
const current = await readLease(rec.backend, rec.targetId).catch(() => {
|
|
159
|
+
return;
|
|
160
|
+
});
|
|
161
|
+
if (!current || current.nonce !== rec.nonce)
|
|
162
|
+
return;
|
|
163
|
+
await writeFile(leaseFile(current.backend, current.targetId), JSON.stringify({ ...current, lastUsedAt: now }), "utf8").catch(() => {
|
|
164
|
+
return;
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
function describeTarget(targetId, ctx) {
|
|
168
|
+
const url = ctx.url ? ` (url ${ctx.url})` : ctx.title ? ` (title ${JSON.stringify(ctx.title)})` : "";
|
|
169
|
+
return `target '${targetId}'${url}`;
|
|
170
|
+
}
|
|
171
|
+
async function assertLeaseOk(backend, targetId, ctx = {}) {
|
|
172
|
+
const token = ctx.lease ?? currentLease();
|
|
173
|
+
const now = ctx.now ?? Date.now();
|
|
174
|
+
const rec = await readLease(backend, targetId);
|
|
175
|
+
const held = rec && !staleReason(rec, { now, liveIds: ctx.liveIds }) ? rec : undefined;
|
|
176
|
+
const where = describeTarget(targetId, ctx);
|
|
177
|
+
if (token === undefined) {
|
|
178
|
+
if (!held)
|
|
179
|
+
return;
|
|
180
|
+
throw new LeaseConflictError(`${where} is leased by '${held.label}' (pid ${held.pid}). Pass that lease's token as the 'lease' argument, or call release_page to free it. list_leases shows every active lease.`, targetId, held.label);
|
|
181
|
+
}
|
|
182
|
+
const parts = tokenParts(token);
|
|
183
|
+
if (!parts) {
|
|
184
|
+
throw new LeaseConflictError(`malformed lease token for ${where}. Pass back the token claim_page returned, never a constructed one.`, targetId);
|
|
185
|
+
}
|
|
186
|
+
if (parts.backend !== backend || parts.targetId !== targetId) {
|
|
187
|
+
if (!held)
|
|
188
|
+
return;
|
|
189
|
+
throw new LeaseConflictError(`the lease token you passed is for ${parts.backend} target '${parts.targetId}', not ${where}, which is leased by '${held.label}'. Resolve the tab you actually hold, or claim this one.`, targetId, held.label);
|
|
190
|
+
}
|
|
191
|
+
if (!held) {
|
|
192
|
+
throw new LeaseConflictError(`${where} is not leased any more: the token you passed belongs to a lease that was released or reclaimed. Call claim_page again to take the tab.`, targetId);
|
|
193
|
+
}
|
|
194
|
+
if (parts.nonce !== held.nonce) {
|
|
195
|
+
throw new LeaseConflictError(`${where} was reclaimed and is now leased by '${held.label}' (pid ${held.pid}). Your token was invalidated by that reclamation. Call claim_page again if you still need this tab.`, targetId, held.label);
|
|
196
|
+
}
|
|
197
|
+
await touchLease(held, now);
|
|
198
|
+
}
|
|
199
|
+
var LEASE_FILE_RE = /^lease-(chrome|firefox)-(.+)\.json$/;
|
|
200
|
+
async function listLeases(opts = {}) {
|
|
201
|
+
const now = opts.now ?? Date.now();
|
|
202
|
+
let names = [];
|
|
203
|
+
try {
|
|
204
|
+
names = await readdir(leaseDir());
|
|
205
|
+
} catch (err) {
|
|
206
|
+
if (err.code === "ENOENT")
|
|
207
|
+
return [];
|
|
208
|
+
throw err;
|
|
209
|
+
}
|
|
210
|
+
const out = [];
|
|
211
|
+
for (const name of names) {
|
|
212
|
+
const parts = LEASE_FILE_RE.exec(name);
|
|
213
|
+
if (!parts)
|
|
214
|
+
continue;
|
|
215
|
+
const fromName = {
|
|
216
|
+
backend: parts[1],
|
|
217
|
+
targetId: parts[2],
|
|
218
|
+
label: "",
|
|
219
|
+
pid: 0,
|
|
220
|
+
createdAt: 0,
|
|
221
|
+
lastUsedAt: 0,
|
|
222
|
+
ttlMs: 0,
|
|
223
|
+
pidAlive: false,
|
|
224
|
+
stale: false
|
|
225
|
+
};
|
|
226
|
+
let rec;
|
|
227
|
+
try {
|
|
228
|
+
rec = JSON.parse(await readFile(join(leaseDir(), name), "utf8"));
|
|
229
|
+
} catch (err) {
|
|
230
|
+
const code = err.code;
|
|
231
|
+
if (code === "ENOENT")
|
|
232
|
+
continue;
|
|
233
|
+
out.push({ ...fromName, unreadable: code ?? "unparseable" });
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (typeof rec?.nonce !== "string" || typeof rec?.pid !== "number") {
|
|
237
|
+
out.push({ ...fromName, unreadable: "unparseable" });
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
out.push({
|
|
241
|
+
backend: rec.backend,
|
|
242
|
+
targetId: rec.targetId,
|
|
243
|
+
label: rec.label,
|
|
244
|
+
pid: rec.pid,
|
|
245
|
+
createdAt: rec.createdAt,
|
|
246
|
+
lastUsedAt: rec.lastUsedAt,
|
|
247
|
+
ttlMs: rec.ttlMs,
|
|
248
|
+
pidAlive: isPidAlive(rec.pid),
|
|
249
|
+
stale: staleReason(rec, {
|
|
250
|
+
now,
|
|
251
|
+
liveIds: opts.liveBackend === undefined || opts.liveBackend === rec.backend ? opts.liveIds : undefined
|
|
252
|
+
})
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
257
|
+
|
|
3
258
|
// src/client.ts
|
|
4
259
|
var BASE = process.env.CDP_BASE ?? "http://127.0.0.1:9222";
|
|
5
260
|
var DEFAULT_TIMEOUT_MS = Number(process.env.CDP_TIMEOUT_MS ?? 15000);
|
|
@@ -153,8 +408,13 @@ async function browserWsUrl() {
|
|
|
153
408
|
const v = await httpJson("/json/version");
|
|
154
409
|
return v.webSocketDebuggerUrl;
|
|
155
410
|
}
|
|
156
|
-
async function resolveTarget(selector) {
|
|
411
|
+
async function resolveTarget(selector, opts = {}) {
|
|
157
412
|
const targets = await listTargets();
|
|
413
|
+
const hit = pickTarget(targets, selector);
|
|
414
|
+
await assertLeaseOk("chrome", hit.id, { lease: opts.lease, url: hit.url, title: hit.title });
|
|
415
|
+
return hit;
|
|
416
|
+
}
|
|
417
|
+
function pickTarget(targets, selector) {
|
|
158
418
|
const pages = targets.filter((t) => t.type === "page");
|
|
159
419
|
if (!selector || selector === "active") {
|
|
160
420
|
if (!pages.length)
|
|
@@ -190,7 +450,7 @@ async function openBrowser(opts = {}) {
|
|
|
190
450
|
return new CdpConnection(await browserWsUrl(), opts).connect();
|
|
191
451
|
}
|
|
192
452
|
async function openPage(selector, opts = {}) {
|
|
193
|
-
const target = await resolveTarget(selector);
|
|
453
|
+
const target = await resolveTarget(selector, { lease: opts.lease });
|
|
194
454
|
const conn = await new CdpConnection(target.webSocketDebuggerUrl, opts).connect();
|
|
195
455
|
return { conn, target };
|
|
196
456
|
}
|
|
@@ -447,6 +707,31 @@ function decodeUid(uid) {
|
|
|
447
707
|
throw driverError("foreign-uid", `malformed cdp uid payload: ${uid}`);
|
|
448
708
|
return n;
|
|
449
709
|
}
|
|
710
|
+
function normalizeCdpCookie(c) {
|
|
711
|
+
const same = (c.sameSite ?? "").toLowerCase();
|
|
712
|
+
const sameSite = same === "strict" || same === "lax" || same === "none" ? same : "default";
|
|
713
|
+
return {
|
|
714
|
+
name: c.name,
|
|
715
|
+
value: c.value,
|
|
716
|
+
domain: c.domain,
|
|
717
|
+
path: c.path,
|
|
718
|
+
expires: typeof c.expires === "number" ? c.expires : -1,
|
|
719
|
+
size: typeof c.size === "number" ? c.size : Buffer.byteLength(`${c.name}${c.value}`, "utf8"),
|
|
720
|
+
httpOnly: c.httpOnly === true,
|
|
721
|
+
secure: c.secure === true,
|
|
722
|
+
sameSite,
|
|
723
|
+
session: c.session === true
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
function cdpSameSite(sameSite) {
|
|
727
|
+
if (sameSite === "strict")
|
|
728
|
+
return "Strict";
|
|
729
|
+
if (sameSite === "lax")
|
|
730
|
+
return "Lax";
|
|
731
|
+
if (sameSite === "none")
|
|
732
|
+
return "None";
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
450
735
|
async function centerOf(conn, objectId) {
|
|
451
736
|
const fn = "function(){this.scrollIntoView({block:'center',inline:'center'});const r=this.getBoundingClientRect();if(r.width===0&&r.height===0)return null;return {x:r.left+r.width/2,y:r.top+r.height/2};}";
|
|
452
737
|
const { result, exceptionDetails } = await conn.send("Runtime.callFunctionOn", { objectId, functionDeclaration: fn, returnByValue: true });
|
|
@@ -969,6 +1254,38 @@ class CdpPageDriver {
|
|
|
969
1254
|
});
|
|
970
1255
|
});
|
|
971
1256
|
}
|
|
1257
|
+
async getCookies() {
|
|
1258
|
+
await this.ensureDomain("Network");
|
|
1259
|
+
const { cookies } = await this.conn.send("Network.getCookies");
|
|
1260
|
+
return (cookies ?? []).map(normalizeCdpCookie);
|
|
1261
|
+
}
|
|
1262
|
+
async setCookie(params) {
|
|
1263
|
+
await this.ensureDomain("Network");
|
|
1264
|
+
const sameSite = cdpSameSite(params.sameSite);
|
|
1265
|
+
const { success } = await this.conn.send("Network.setCookie", {
|
|
1266
|
+
name: params.name,
|
|
1267
|
+
value: params.value,
|
|
1268
|
+
...params.url !== undefined ? { url: params.url } : {},
|
|
1269
|
+
...params.domain !== undefined ? { domain: params.domain } : {},
|
|
1270
|
+
...params.path !== undefined ? { path: params.path } : {},
|
|
1271
|
+
...params.expires !== undefined ? { expires: params.expires } : {},
|
|
1272
|
+
...params.httpOnly !== undefined ? { httpOnly: params.httpOnly } : {},
|
|
1273
|
+
...params.secure !== undefined ? { secure: params.secure } : {},
|
|
1274
|
+
...sameSite !== undefined ? { sameSite } : {}
|
|
1275
|
+
});
|
|
1276
|
+
if (success !== true) {
|
|
1277
|
+
throw driverError("page-error", `Network.setCookie declined the cookie '${params.name}'. Chrome rejects a cookie whose domain does not match the url, a secure cookie on an insecure origin, and an oversized name or value.`);
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
async deleteCookies(filter) {
|
|
1281
|
+
await this.ensureDomain("Network");
|
|
1282
|
+
await this.conn.send("Network.deleteCookies", {
|
|
1283
|
+
name: filter.name,
|
|
1284
|
+
...filter.url !== undefined ? { url: filter.url } : {},
|
|
1285
|
+
...filter.domain !== undefined ? { domain: filter.domain } : {},
|
|
1286
|
+
...filter.path !== undefined ? { path: filter.path } : {}
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
972
1289
|
async startBodyCapture() {
|
|
973
1290
|
await this.ensureDomain("Network");
|
|
974
1291
|
return async () => {
|
|
@@ -1028,6 +1345,10 @@ async function withBrowserConn(fn) {
|
|
|
1028
1345
|
}
|
|
1029
1346
|
}
|
|
1030
1347
|
function noSuchTarget(e) {
|
|
1348
|
+
if (e instanceof LeaseConflictError)
|
|
1349
|
+
throw e;
|
|
1350
|
+
if (typeof e?.code === "string")
|
|
1351
|
+
throw e;
|
|
1031
1352
|
throw driverError("no-such-target", e instanceof CdpError ? e.message : String(e));
|
|
1032
1353
|
}
|
|
1033
1354
|
|
|
@@ -1071,9 +1392,154 @@ function createCdpDriver() {
|
|
|
1071
1392
|
return new CdpBrowserDriver;
|
|
1072
1393
|
}
|
|
1073
1394
|
|
|
1395
|
+
// src/origins.ts
|
|
1396
|
+
import { mkdir as mkdir2, readFile as readFile2, readdir as readdir2, unlink as unlink2, writeFile as writeFile2 } from "node:fs/promises";
|
|
1397
|
+
import { join as join2 } from "node:path";
|
|
1398
|
+
function safeId2(id) {
|
|
1399
|
+
return id.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
1400
|
+
}
|
|
1401
|
+
function originFile(backend, targetId) {
|
|
1402
|
+
return join2(leaseDir(), `origin-${backend}-${safeId2(targetId)}.json`);
|
|
1403
|
+
}
|
|
1404
|
+
var ORIGIN_FILE_RE = /^origin-(chrome|firefox)-(.+)\.json$/;
|
|
1405
|
+
async function recordOrigin(backend, targetId, opts = {}) {
|
|
1406
|
+
const record = {
|
|
1407
|
+
backend,
|
|
1408
|
+
targetId,
|
|
1409
|
+
label: typeof opts.label === "string" && opts.label.length ? opts.label : defaultLabel(),
|
|
1410
|
+
pid: process.pid,
|
|
1411
|
+
createdAt: opts.now ?? Date.now()
|
|
1412
|
+
};
|
|
1413
|
+
const file = originFile(backend, targetId);
|
|
1414
|
+
try {
|
|
1415
|
+
await mkdir2(leaseDir(), { recursive: true });
|
|
1416
|
+
try {
|
|
1417
|
+
await writeFile2(file, JSON.stringify(record), { flag: "wx" });
|
|
1418
|
+
return;
|
|
1419
|
+
} catch (err) {
|
|
1420
|
+
if (err.code !== "EEXIST")
|
|
1421
|
+
throw err;
|
|
1422
|
+
}
|
|
1423
|
+
await unlink2(file).catch(() => {
|
|
1424
|
+
return;
|
|
1425
|
+
});
|
|
1426
|
+
await writeFile2(file, JSON.stringify(record), { flag: "wx" });
|
|
1427
|
+
} catch {}
|
|
1428
|
+
}
|
|
1429
|
+
async function listOrigins(opts = {}) {
|
|
1430
|
+
let names = [];
|
|
1431
|
+
try {
|
|
1432
|
+
names = await readdir2(leaseDir());
|
|
1433
|
+
} catch {
|
|
1434
|
+
return [];
|
|
1435
|
+
}
|
|
1436
|
+
const live = opts.liveIds === undefined ? undefined : new Set(opts.liveIds.map((id) => safeId2(id)));
|
|
1437
|
+
const out = [];
|
|
1438
|
+
for (const name of names) {
|
|
1439
|
+
const parts = ORIGIN_FILE_RE.exec(name);
|
|
1440
|
+
if (!parts)
|
|
1441
|
+
continue;
|
|
1442
|
+
const backend = parts[1];
|
|
1443
|
+
const nameId = parts[2];
|
|
1444
|
+
const scoped = live !== undefined && (opts.liveBackend === undefined || opts.liveBackend === backend);
|
|
1445
|
+
if (scoped && !live.has(nameId)) {
|
|
1446
|
+
await unlink2(join2(leaseDir(), name)).catch(() => {
|
|
1447
|
+
return;
|
|
1448
|
+
});
|
|
1449
|
+
continue;
|
|
1450
|
+
}
|
|
1451
|
+
const fromName = { backend, targetId: nameId, label: "", pid: 0, createdAt: 0 };
|
|
1452
|
+
let rec;
|
|
1453
|
+
try {
|
|
1454
|
+
rec = JSON.parse(await readFile2(join2(leaseDir(), name), "utf8"));
|
|
1455
|
+
} catch (err) {
|
|
1456
|
+
const code = err.code;
|
|
1457
|
+
if (code === "ENOENT")
|
|
1458
|
+
continue;
|
|
1459
|
+
out.push({ ...fromName, unreadable: code ?? "unparseable" });
|
|
1460
|
+
continue;
|
|
1461
|
+
}
|
|
1462
|
+
if (typeof rec?.targetId !== "string" || typeof rec?.label !== "string" || typeof rec?.createdAt !== "number") {
|
|
1463
|
+
out.push({ ...fromName, unreadable: "unparseable" });
|
|
1464
|
+
continue;
|
|
1465
|
+
}
|
|
1466
|
+
out.push({ backend: rec.backend, targetId: rec.targetId, label: rec.label, pid: rec.pid, createdAt: rec.createdAt });
|
|
1467
|
+
}
|
|
1468
|
+
return out;
|
|
1469
|
+
}
|
|
1470
|
+
async function originIndex(backend, liveIds) {
|
|
1471
|
+
const rows = await listOrigins({ liveIds, liveBackend: backend }).catch(() => []);
|
|
1472
|
+
const index = new Map;
|
|
1473
|
+
for (const row of rows) {
|
|
1474
|
+
if (row.backend !== backend)
|
|
1475
|
+
continue;
|
|
1476
|
+
index.set(row.targetId, row);
|
|
1477
|
+
}
|
|
1478
|
+
return index;
|
|
1479
|
+
}
|
|
1480
|
+
async function newTrackedPage(driver, backend, opts = {}) {
|
|
1481
|
+
const page = await driver.newPage(opts.url);
|
|
1482
|
+
await recordOrigin(backend, page.id, { label: opts.label });
|
|
1483
|
+
return page;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
// src/leases-tools.ts
|
|
1487
|
+
class LeaseToolError extends Error {
|
|
1488
|
+
}
|
|
1489
|
+
function backendOf(driver) {
|
|
1490
|
+
return driver.scheme === "bidi" ? "firefox" : "chrome";
|
|
1491
|
+
}
|
|
1492
|
+
async function claimPage(driver, args = {}) {
|
|
1493
|
+
const backend = backendOf(driver);
|
|
1494
|
+
const label = typeof args.label === "string" && args.label.length ? args.label : defaultLabel();
|
|
1495
|
+
const pages = await driver.listPages();
|
|
1496
|
+
let targetId = args.targetId;
|
|
1497
|
+
let url = args.url ?? "about:blank";
|
|
1498
|
+
if (targetId === undefined || targetId === "") {
|
|
1499
|
+
const page = await newTrackedPage(driver, backend, { url: args.url, label });
|
|
1500
|
+
targetId = page.id;
|
|
1501
|
+
url = page.url;
|
|
1502
|
+
} else {
|
|
1503
|
+
const existing = pages.find((p) => p.id === targetId);
|
|
1504
|
+
if (!existing)
|
|
1505
|
+
throw new LeaseToolError(`claim_page: no page target with id '${targetId}'`);
|
|
1506
|
+
url = existing.url;
|
|
1507
|
+
}
|
|
1508
|
+
const liveIds = [...pages.map((p) => p.id), targetId];
|
|
1509
|
+
const { record, token } = await claimLease(backend, targetId, {
|
|
1510
|
+
label,
|
|
1511
|
+
ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
|
|
1512
|
+
liveIds
|
|
1513
|
+
});
|
|
1514
|
+
return {
|
|
1515
|
+
lease: token,
|
|
1516
|
+
targetId,
|
|
1517
|
+
url,
|
|
1518
|
+
label: record.label,
|
|
1519
|
+
ttlMs: record.ttlMs,
|
|
1520
|
+
expiresAt: record.lastUsedAt + record.ttlMs
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
async function releasePage(_driver, args) {
|
|
1524
|
+
if (typeof args.lease !== "string" || !args.lease.length) {
|
|
1525
|
+
throw new LeaseToolError("release_page requires the 'lease' token returned by claim_page");
|
|
1526
|
+
}
|
|
1527
|
+
return releaseLease(args.lease);
|
|
1528
|
+
}
|
|
1529
|
+
async function listLeasesTool(driver, _args = {}) {
|
|
1530
|
+
const liveIds = (await driver.listPages()).map((p) => p.id);
|
|
1531
|
+
const leases = await listLeases({ liveIds, liveBackend: backendOf(driver) });
|
|
1532
|
+
return { leases, count: leases.length };
|
|
1533
|
+
}
|
|
1534
|
+
var LEASE_TOOLS = {
|
|
1535
|
+
claim_page: claimPage,
|
|
1536
|
+
release_page: releasePage,
|
|
1537
|
+
list_leases: listLeasesTool
|
|
1538
|
+
};
|
|
1539
|
+
|
|
1074
1540
|
// src/shared-tools.ts
|
|
1075
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
1076
|
-
import { join } from "node:path";
|
|
1541
|
+
import { mkdir as mkdir3, writeFile as writeFile3 } from "node:fs/promises";
|
|
1542
|
+
import { dirname, join as join3 } from "node:path";
|
|
1077
1543
|
var ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
1078
1544
|
var STATE_DIR = process.env.CDP_STATE_DIR ?? "/tmp/cdp-toolkit";
|
|
1079
1545
|
|
|
@@ -1082,6 +1548,9 @@ class SharedToolError extends Error {
|
|
|
1082
1548
|
function target3(p) {
|
|
1083
1549
|
return { id: p.id, url: p.url, title: p.title };
|
|
1084
1550
|
}
|
|
1551
|
+
function backendOf2(driver) {
|
|
1552
|
+
return driver.scheme === "bidi" ? "firefox" : "chrome";
|
|
1553
|
+
}
|
|
1085
1554
|
async function withPage2(driver, target, fn) {
|
|
1086
1555
|
const page = await driver.page(target);
|
|
1087
1556
|
try {
|
|
@@ -1092,6 +1561,11 @@ async function withPage2(driver, target, fn) {
|
|
|
1092
1561
|
}
|
|
1093
1562
|
async function resolvePage(driver, selector) {
|
|
1094
1563
|
const pages = await driver.listPages();
|
|
1564
|
+
const hit = await pickPage(driver, pages, selector);
|
|
1565
|
+
await assertLeaseOk(backendOf2(driver), hit.id, { url: hit.url, title: hit.title });
|
|
1566
|
+
return hit;
|
|
1567
|
+
}
|
|
1568
|
+
async function pickPage(driver, pages, selector) {
|
|
1095
1569
|
if (selector === undefined || selector === "active") {
|
|
1096
1570
|
if (!pages.length)
|
|
1097
1571
|
throw new SharedToolError("no page targets open");
|
|
@@ -1133,24 +1607,52 @@ function locatorOf(args) {
|
|
|
1133
1607
|
throw new SharedToolError("provide exactly one of { uid } or { selector }");
|
|
1134
1608
|
return hasUid ? { uid: args.uid } : { css: args.selector };
|
|
1135
1609
|
}
|
|
1610
|
+
async function reapSet(driver, pages, all) {
|
|
1611
|
+
if (all === true)
|
|
1612
|
+
return pages.map((p) => p.id);
|
|
1613
|
+
try {
|
|
1614
|
+
return (await driver.listPages({ all: true })).map((p) => p.id);
|
|
1615
|
+
} catch {
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1136
1619
|
async function listPages(driver, args = {}) {
|
|
1137
1620
|
const pages = await driver.listPages({ all: args.all });
|
|
1138
|
-
|
|
1621
|
+
const ledger = await originIndex(backendOf2(driver), await reapSet(driver, pages, args.all));
|
|
1622
|
+
const annotated = pages.map((p) => {
|
|
1623
|
+
const rec = ledger.get(p.id);
|
|
1624
|
+
if (!rec)
|
|
1625
|
+
return { ...p, origin: "unknown" };
|
|
1626
|
+
if (rec.unreadable !== undefined)
|
|
1627
|
+
return { ...p, origin: "unknown", originUnreadable: rec.unreadable };
|
|
1628
|
+
return { ...p, origin: "agent", label: rec.label, createdAt: rec.createdAt };
|
|
1629
|
+
});
|
|
1630
|
+
return { pages: annotated, count: annotated.length };
|
|
1139
1631
|
}
|
|
1140
1632
|
async function newPage(driver, args = {}) {
|
|
1141
|
-
const
|
|
1142
|
-
|
|
1633
|
+
const label = typeof args.label === "string" && args.label.length ? args.label : defaultLabel();
|
|
1634
|
+
const p = await newTrackedPage(driver, backendOf2(driver), { url: args.url, label });
|
|
1635
|
+
if (args.claim !== true)
|
|
1636
|
+
return { targetId: p.id, url: p.url };
|
|
1637
|
+
const { record, token } = await claimLease(backendOf2(driver), p.id, {
|
|
1638
|
+
label,
|
|
1639
|
+
ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs()
|
|
1640
|
+
});
|
|
1641
|
+
return { targetId: p.id, url: p.url, lease: token, label: record.label, expiresAt: record.lastUsedAt + record.ttlMs };
|
|
1143
1642
|
}
|
|
1144
1643
|
async function closePage(driver, args) {
|
|
1145
1644
|
if (args.target === undefined || args.target === "")
|
|
1146
1645
|
throw new SharedToolError("close_page requires an explicit target; refusing to guess which page to close");
|
|
1147
1646
|
const p = await resolvePage(driver, args.target);
|
|
1148
1647
|
const res = await driver.closePage(p.id);
|
|
1149
|
-
|
|
1648
|
+
if (!res.success)
|
|
1649
|
+
return { closed: p.id, success: false, leaseReleased: false };
|
|
1650
|
+
const released = await releaseLeaseFor(backendOf2(driver), p.id);
|
|
1651
|
+
return { closed: p.id, success: true, ...released.released ? { leaseReleased: true } : {} };
|
|
1150
1652
|
}
|
|
1151
1653
|
async function writeSelectedFile(id) {
|
|
1152
|
-
await
|
|
1153
|
-
await
|
|
1654
|
+
await mkdir3(STATE_DIR, { recursive: true });
|
|
1655
|
+
await writeFile3(join3(STATE_DIR, "selected"), id, "utf8");
|
|
1154
1656
|
}
|
|
1155
1657
|
async function selectPage(driver, args) {
|
|
1156
1658
|
if (args.target === undefined || args.target === "")
|
|
@@ -1190,11 +1692,108 @@ async function waitForText(driver, args) {
|
|
|
1190
1692
|
return { found: true, waitedMs: r.elapsedMs };
|
|
1191
1693
|
});
|
|
1192
1694
|
}
|
|
1695
|
+
function serializeForSink(value) {
|
|
1696
|
+
const json = JSON.stringify(value === undefined ? null : value, null, 2);
|
|
1697
|
+
return json ?? "null";
|
|
1698
|
+
}
|
|
1699
|
+
async function writeJsonSink(savePath, value) {
|
|
1700
|
+
const path = savePath.startsWith("/") ? savePath : join3(ARTIFACT_DIR, savePath);
|
|
1701
|
+
await mkdir3(dirname(path), { recursive: true });
|
|
1702
|
+
const json = serializeForSink(value);
|
|
1703
|
+
await writeFile3(path, json, "utf8");
|
|
1704
|
+
return { path, bytes: Buffer.byteLength(json, "utf8") };
|
|
1705
|
+
}
|
|
1706
|
+
function sinkRequested(savePath) {
|
|
1707
|
+
return savePath !== undefined && savePath !== "";
|
|
1708
|
+
}
|
|
1193
1709
|
async function evaluateScript(driver, args) {
|
|
1194
1710
|
if (typeof args.expression !== "string" || args.expression.length === 0) {
|
|
1195
1711
|
throw new SharedToolError("evaluateScript: 'expression' must be a non-empty string");
|
|
1196
1712
|
}
|
|
1197
|
-
return withPage2(driver, args.target, (page) =>
|
|
1713
|
+
return withPage2(driver, args.target, async (page) => {
|
|
1714
|
+
const value = await page.evaluate(args.expression, { args: args.args, awaitPromise: args.awaitPromise ?? true });
|
|
1715
|
+
if (!sinkRequested(args.savePath))
|
|
1716
|
+
return value;
|
|
1717
|
+
const { path, bytes } = await writeJsonSink(args.savePath, value);
|
|
1718
|
+
const result = {
|
|
1719
|
+
path,
|
|
1720
|
+
bytes,
|
|
1721
|
+
type: value === null ? "null" : typeof value,
|
|
1722
|
+
target: target3(page.info)
|
|
1723
|
+
};
|
|
1724
|
+
return result;
|
|
1725
|
+
});
|
|
1726
|
+
}
|
|
1727
|
+
function cookieDomainMatches(cookieDomain, filter) {
|
|
1728
|
+
const bare = (d) => (d.startsWith(".") ? d.slice(1) : d).toLowerCase();
|
|
1729
|
+
const c = bare(cookieDomain);
|
|
1730
|
+
const f = bare(filter);
|
|
1731
|
+
return c === f || c.endsWith(`.${f}`);
|
|
1732
|
+
}
|
|
1733
|
+
function filterCookies(cookies, filter) {
|
|
1734
|
+
return cookies.filter((c) => {
|
|
1735
|
+
if (filter.name !== undefined && filter.name !== "" && c.name !== filter.name)
|
|
1736
|
+
return false;
|
|
1737
|
+
if (filter.domain !== undefined && filter.domain !== "" && !cookieDomainMatches(c.domain, filter.domain))
|
|
1738
|
+
return false;
|
|
1739
|
+
return true;
|
|
1740
|
+
});
|
|
1741
|
+
}
|
|
1742
|
+
async function listCookies(driver, args = {}) {
|
|
1743
|
+
return withPage2(driver, args.target, async (page) => {
|
|
1744
|
+
const all = await page.getCookies();
|
|
1745
|
+
const cookies = filterCookies(all, { domain: args.domain, name: args.name });
|
|
1746
|
+
if (!sinkRequested(args.savePath)) {
|
|
1747
|
+
return { cookies, count: cookies.length, target: target3(page.info) };
|
|
1748
|
+
}
|
|
1749
|
+
const { path, bytes } = await writeJsonSink(args.savePath, cookies);
|
|
1750
|
+
return { path, bytes, count: cookies.length, target: target3(page.info) };
|
|
1751
|
+
});
|
|
1752
|
+
}
|
|
1753
|
+
function assertCookieSite(args, toolName) {
|
|
1754
|
+
const hasUrl = args.url !== undefined && args.url !== "";
|
|
1755
|
+
const hasDomain = args.domain !== undefined && args.domain !== "";
|
|
1756
|
+
if (!hasUrl && !hasDomain) {
|
|
1757
|
+
throw new Error(`${toolName} requires either 'url' or 'domain': a cookie has to be attributed to a site, and neither was given.`);
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
function assertCookieName(name, toolName) {
|
|
1761
|
+
if (name === undefined || name === "") {
|
|
1762
|
+
throw new Error(`${toolName} requires a non-empty 'name'.`);
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
async function setCookie(driver, args) {
|
|
1766
|
+
assertCookieName(args.name, "set_cookie");
|
|
1767
|
+
if (typeof args.value !== "string")
|
|
1768
|
+
throw new Error("set_cookie requires a string 'value'.");
|
|
1769
|
+
assertCookieSite(args, "set_cookie");
|
|
1770
|
+
return withPage2(driver, args.target, async (page) => {
|
|
1771
|
+
await page.setCookie({
|
|
1772
|
+
name: args.name,
|
|
1773
|
+
value: args.value,
|
|
1774
|
+
...args.url !== undefined ? { url: args.url } : {},
|
|
1775
|
+
...args.domain !== undefined ? { domain: args.domain } : {},
|
|
1776
|
+
...args.path !== undefined ? { path: args.path } : {},
|
|
1777
|
+
...args.expires !== undefined ? { expires: args.expires } : {},
|
|
1778
|
+
...args.httpOnly !== undefined ? { httpOnly: args.httpOnly } : {},
|
|
1779
|
+
...args.secure !== undefined ? { secure: args.secure } : {},
|
|
1780
|
+
...args.sameSite !== undefined ? { sameSite: args.sameSite } : {}
|
|
1781
|
+
});
|
|
1782
|
+
return { set: true, target: target3(page.info) };
|
|
1783
|
+
});
|
|
1784
|
+
}
|
|
1785
|
+
async function deleteCookies(driver, args) {
|
|
1786
|
+
assertCookieName(args.name, "delete_cookies");
|
|
1787
|
+
assertCookieSite(args, "delete_cookies");
|
|
1788
|
+
return withPage2(driver, args.target, async (page) => {
|
|
1789
|
+
await page.deleteCookies({
|
|
1790
|
+
name: args.name,
|
|
1791
|
+
...args.url !== undefined ? { url: args.url } : {},
|
|
1792
|
+
...args.domain !== undefined ? { domain: args.domain } : {},
|
|
1793
|
+
...args.path !== undefined ? { path: args.path } : {}
|
|
1794
|
+
});
|
|
1795
|
+
return { deleted: true, target: target3(page.info) };
|
|
1796
|
+
});
|
|
1198
1797
|
}
|
|
1199
1798
|
var BARE_TOKEN_EXTRAS = new Set(["disabled", "focused"]);
|
|
1200
1799
|
var UNQUOTED_EXTRAS = new Set(["checked", "expanded", "url"]);
|
|
@@ -1305,10 +1904,10 @@ async function takeScreenshot(driver, args = {}) {
|
|
|
1305
1904
|
const { data, format: outFormat } = await page.screenshot({ format, quality: args.quality, fullPage: args.fullPage, ...clip ? { clip } : {} });
|
|
1306
1905
|
if (data.byteLength === 0)
|
|
1307
1906
|
throw new SharedToolError("captureScreenshot returned empty data");
|
|
1308
|
-
await
|
|
1907
|
+
await mkdir3(ARTIFACT_DIR, { recursive: true });
|
|
1309
1908
|
const ext = outFormat === "jpeg" ? "jpg" : "png";
|
|
1310
|
-
const path = args.savePath ??
|
|
1311
|
-
await
|
|
1909
|
+
const path = args.savePath ?? join3(ARTIFACT_DIR, `screenshot-${page.info.id.slice(0, 8)}-${stamp()}.${ext}`);
|
|
1910
|
+
await writeFile3(path, Buffer.from(data));
|
|
1312
1911
|
const result = {
|
|
1313
1912
|
path,
|
|
1314
1913
|
bytes: data.byteLength,
|
|
@@ -1355,6 +1954,9 @@ var SHARED_TOOLS = {
|
|
|
1355
1954
|
navigate_page: navigatePage,
|
|
1356
1955
|
wait_for: waitForText,
|
|
1357
1956
|
evaluate_script: evaluateScript,
|
|
1957
|
+
list_cookies: listCookies,
|
|
1958
|
+
set_cookie: setCookie,
|
|
1959
|
+
delete_cookies: deleteCookies,
|
|
1358
1960
|
take_snapshot: takeSnapshot,
|
|
1359
1961
|
click,
|
|
1360
1962
|
hover,
|
|
@@ -1371,10 +1973,10 @@ var SHARED_TOOLS = {
|
|
|
1371
1973
|
};
|
|
1372
1974
|
|
|
1373
1975
|
// src/tools/console.ts
|
|
1374
|
-
import { readFile } from "node:fs/promises";
|
|
1976
|
+
import { readFile as readFile3 } from "node:fs/promises";
|
|
1375
1977
|
|
|
1376
1978
|
// src/tools/recorder.ts
|
|
1377
|
-
import { mkdir as
|
|
1979
|
+
import { mkdir as mkdir4, appendFile, writeFile as writeFile4, copyFile } from "node:fs/promises";
|
|
1378
1980
|
var ARTIFACT_DIR2 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
1379
1981
|
function recFile(targetId) {
|
|
1380
1982
|
return `${ARTIFACT_DIR2}/rec-${targetId}.jsonl`;
|
|
@@ -1395,11 +1997,11 @@ var CONSOLE_EVENTS = ["Runtime.consoleAPICalled", "Runtime.exceptionThrown", "Lo
|
|
|
1395
1997
|
async function startRecorder(target, opts = {}) {
|
|
1396
1998
|
const network = opts.network ?? true;
|
|
1397
1999
|
const consoleCap = opts.console ?? true;
|
|
1398
|
-
await
|
|
2000
|
+
await mkdir4(ARTIFACT_DIR2, { recursive: true });
|
|
1399
2001
|
const { conn, target: resolved } = await openPage(target, { timeoutMs: opts.timeoutMs });
|
|
1400
2002
|
const file = opts.file ?? recFile(resolved.id);
|
|
1401
2003
|
if (opts.truncate) {
|
|
1402
|
-
await
|
|
2004
|
+
await writeFile4(file, "");
|
|
1403
2005
|
}
|
|
1404
2006
|
let chain = Promise.resolve();
|
|
1405
2007
|
let dropped = 0;
|
|
@@ -1541,7 +2143,7 @@ function lineToEntry(line, index) {
|
|
|
1541
2143
|
async function readConsoleEntries(file) {
|
|
1542
2144
|
let raw;
|
|
1543
2145
|
try {
|
|
1544
|
-
raw = await
|
|
2146
|
+
raw = await readFile3(file, "utf8");
|
|
1545
2147
|
} catch {
|
|
1546
2148
|
return [];
|
|
1547
2149
|
}
|
|
@@ -1597,7 +2199,7 @@ async function getConsoleMessage(args = {}) {
|
|
|
1597
2199
|
}
|
|
1598
2200
|
|
|
1599
2201
|
// src/tools/network.ts
|
|
1600
|
-
import { readFile as
|
|
2202
|
+
import { readFile as readFile4 } from "node:fs/promises";
|
|
1601
2203
|
var DEFAULT_CAPTURE_MS2 = 2500;
|
|
1602
2204
|
function asHeaders(v) {
|
|
1603
2205
|
if (!v || typeof v !== "object")
|
|
@@ -1664,7 +2266,7 @@ function correlate(lines) {
|
|
|
1664
2266
|
async function readNetworkRequests(file) {
|
|
1665
2267
|
let raw;
|
|
1666
2268
|
try {
|
|
1667
|
-
raw = await
|
|
2269
|
+
raw = await readFile4(file, "utf8");
|
|
1668
2270
|
} catch {
|
|
1669
2271
|
return [];
|
|
1670
2272
|
}
|
|
@@ -1746,8 +2348,8 @@ async function getNetworkRequest(args = {}) {
|
|
|
1746
2348
|
}
|
|
1747
2349
|
|
|
1748
2350
|
// src/tools/performance.ts
|
|
1749
|
-
import { mkdir as
|
|
1750
|
-
import { join as
|
|
2351
|
+
import { mkdir as mkdir5, readFile as readFile5, writeFile as writeFile5, stat, unlink as unlink3 } from "node:fs/promises";
|
|
2352
|
+
import { join as join4 } from "node:path";
|
|
1751
2353
|
var ARTIFACT_DIR3 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
1752
2354
|
var STATE_DIR2 = process.env.CDP_STATE_DIR ?? "/tmp/cdp-toolkit";
|
|
1753
2355
|
var DEFAULT_CATEGORIES = [
|
|
@@ -1765,7 +2367,7 @@ function shortId(id) {
|
|
|
1765
2367
|
return id.slice(0, 8);
|
|
1766
2368
|
}
|
|
1767
2369
|
function stateFilePath() {
|
|
1768
|
-
return
|
|
2370
|
+
return join4(STATE_DIR2, "trace-state.json");
|
|
1769
2371
|
}
|
|
1770
2372
|
function bufferTraceData(conn, sink) {
|
|
1771
2373
|
return conn.on("Tracing.dataCollected", (params) => {
|
|
@@ -1787,10 +2389,10 @@ async function endTrace(conn, sink, timeoutMs = 30000) {
|
|
|
1787
2389
|
return sink;
|
|
1788
2390
|
}
|
|
1789
2391
|
async function writeTrace(events, targetId) {
|
|
1790
|
-
await
|
|
1791
|
-
const path =
|
|
2392
|
+
await mkdir5(ARTIFACT_DIR3, { recursive: true });
|
|
2393
|
+
const path = join4(ARTIFACT_DIR3, `trace-${shortId(targetId)}-${stamp2()}.json`);
|
|
1792
2394
|
const body = JSON.stringify({ traceEvents: events, metadata: { source: "cdp-toolkit" } });
|
|
1793
|
-
await
|
|
2395
|
+
await writeFile5(path, body, "utf8");
|
|
1794
2396
|
const { size } = await stat(path);
|
|
1795
2397
|
return { path, bytes: size };
|
|
1796
2398
|
}
|
|
@@ -1810,7 +2412,7 @@ async function performanceStartTrace(args = {}) {
|
|
|
1810
2412
|
throw err;
|
|
1811
2413
|
}
|
|
1812
2414
|
liveTraces.set(target.id, { conn, target, events, startedAt: Date.now() });
|
|
1813
|
-
await
|
|
2415
|
+
await mkdir5(STATE_DIR2, { recursive: true });
|
|
1814
2416
|
const state = {
|
|
1815
2417
|
targetId: target.id,
|
|
1816
2418
|
url: target.url,
|
|
@@ -1818,7 +2420,7 @@ async function performanceStartTrace(args = {}) {
|
|
|
1818
2420
|
pid: process.pid,
|
|
1819
2421
|
categories
|
|
1820
2422
|
};
|
|
1821
|
-
await
|
|
2423
|
+
await writeFile5(stateFilePath(), JSON.stringify(state), "utf8");
|
|
1822
2424
|
return {
|
|
1823
2425
|
target: { id: target.id, url: target.url, title: target.title },
|
|
1824
2426
|
categories,
|
|
@@ -1831,7 +2433,7 @@ async function performanceStopTrace(args = {}) {
|
|
|
1831
2433
|
if (!entry) {
|
|
1832
2434
|
let hint = "";
|
|
1833
2435
|
try {
|
|
1834
|
-
const raw = await
|
|
2436
|
+
const raw = await readFile5(stateFilePath(), "utf8");
|
|
1835
2437
|
const state = JSON.parse(raw);
|
|
1836
2438
|
hint = state.pid === process.pid ? ` (state file lists target ${shortId(state.targetId)} but its live connection is gone)` : ` (trace was started by a DIFFERENT process pid=${state.pid}; live recordings cannot cross process boundaries)`;
|
|
1837
2439
|
} catch {}
|
|
@@ -1843,7 +2445,7 @@ async function performanceStopTrace(args = {}) {
|
|
|
1843
2445
|
} finally {
|
|
1844
2446
|
conn.close();
|
|
1845
2447
|
liveTraces.delete(target.id);
|
|
1846
|
-
await
|
|
2448
|
+
await unlink3(stateFilePath()).catch(() => {});
|
|
1847
2449
|
}
|
|
1848
2450
|
const written = await writeTrace(events, target.id);
|
|
1849
2451
|
const metrics = analyzeEvents(events);
|
|
@@ -1961,7 +2563,7 @@ async function performanceAnalyzeInsight(args = {}) {
|
|
|
1961
2563
|
}
|
|
1962
2564
|
let raw;
|
|
1963
2565
|
try {
|
|
1964
|
-
raw = await
|
|
2566
|
+
raw = await readFile5(args.tracePath, "utf8");
|
|
1965
2567
|
} catch (err) {
|
|
1966
2568
|
throw new CdpError(`cannot read trace file '${args.tracePath}': ${err.message}`);
|
|
1967
2569
|
}
|
|
@@ -1978,8 +2580,8 @@ async function performanceAnalyzeInsight(args = {}) {
|
|
|
1978
2580
|
}
|
|
1979
2581
|
|
|
1980
2582
|
// src/tools/heap.ts
|
|
1981
|
-
import { mkdir as
|
|
1982
|
-
import { join as
|
|
2583
|
+
import { mkdir as mkdir6, writeFile as writeFile6 } from "node:fs/promises";
|
|
2584
|
+
import { join as join5 } from "node:path";
|
|
1983
2585
|
var ARTIFACT_DIR4 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
1984
2586
|
function stamp3(targetId) {
|
|
1985
2587
|
const short = targetId.slice(0, 8);
|
|
@@ -2004,9 +2606,9 @@ async function takeHeapsnapshot(args = {}) {
|
|
|
2004
2606
|
if (snapshot.length === 0) {
|
|
2005
2607
|
throw new Error("HeapProfiler.takeHeapSnapshot produced no chunks");
|
|
2006
2608
|
}
|
|
2007
|
-
await
|
|
2008
|
-
const path = args.savePath ? args.savePath.startsWith("/") ? args.savePath :
|
|
2009
|
-
await
|
|
2609
|
+
await mkdir6(ARTIFACT_DIR4, { recursive: true });
|
|
2610
|
+
const path = args.savePath ? args.savePath.startsWith("/") ? args.savePath : join5(ARTIFACT_DIR4, args.savePath) : join5(ARTIFACT_DIR4, `take_heapsnapshot-${stamp3(target.id)}.heapsnapshot`);
|
|
2611
|
+
await writeFile6(path, snapshot, "utf8");
|
|
2010
2612
|
const bytes = Buffer.byteLength(snapshot, "utf8");
|
|
2011
2613
|
return {
|
|
2012
2614
|
path,
|
|
@@ -2019,7 +2621,7 @@ async function takeHeapsnapshot(args = {}) {
|
|
|
2019
2621
|
|
|
2020
2622
|
// src/tools/lighthouse.ts
|
|
2021
2623
|
import { spawn } from "node:child_process";
|
|
2022
|
-
import { mkdir as
|
|
2624
|
+
import { mkdir as mkdir7, readFile as readFile6, stat as stat2 } from "node:fs/promises";
|
|
2023
2625
|
var ARTIFACT_DIR5 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
2024
2626
|
function cdpPort() {
|
|
2025
2627
|
const base = process.env.CDP_BASE ?? "http://127.0.0.1:9222";
|
|
@@ -2080,7 +2682,7 @@ async function lighthouseAudit(args) {
|
|
|
2080
2682
|
const url = args.url.trim();
|
|
2081
2683
|
const timeoutMs = args.timeoutMs ?? 120000;
|
|
2082
2684
|
const port = cdpPort();
|
|
2083
|
-
await
|
|
2685
|
+
await mkdir7(ARTIFACT_DIR5, { recursive: true });
|
|
2084
2686
|
const outPath = args.savePath ?? `${ARTIFACT_DIR5}/lighthouse-${shortHost(url)}-${stamp4()}.json`;
|
|
2085
2687
|
const formFactor = args.formFactor ?? "desktop";
|
|
2086
2688
|
const lhArgs = [
|
|
@@ -2109,7 +2711,7 @@ async function lighthouseAudit(args) {
|
|
|
2109
2711
|
}
|
|
2110
2712
|
let raw;
|
|
2111
2713
|
try {
|
|
2112
|
-
raw = await
|
|
2714
|
+
raw = await readFile6(outPath, "utf8");
|
|
2113
2715
|
} catch {
|
|
2114
2716
|
raw = undefined;
|
|
2115
2717
|
}
|
|
@@ -2117,7 +2719,7 @@ async function lighthouseAudit(args) {
|
|
|
2117
2719
|
if (res.signal === "SIGKILL") {
|
|
2118
2720
|
throw new CdpError(`lighthouse_audit timed out after ${timeoutMs}ms (no report written). stderr: ${truncate(res.stderr)}`);
|
|
2119
2721
|
}
|
|
2120
|
-
const hint = /not found|could not determine executable|npm ERR|E404|ERR_MODULE/i.test(res.stderr) ? " The Lighthouse CLI could not be fetched/installed via npx
|
|
2722
|
+
const hint = /not found|could not determine executable|npm ERR|E404|ERR_MODULE/i.test(res.stderr) ? " The Lighthouse CLI could not be fetched/installed via npx; ensure network access and that 'lighthouse' is installable." : "";
|
|
2121
2723
|
throw new CdpError(`lighthouse_audit produced no report (exit code ${res.code}).${hint} stderr: ${truncate(res.stderr)}`);
|
|
2122
2724
|
}
|
|
2123
2725
|
let report;
|
|
@@ -2166,6 +2768,9 @@ var TOOLS = {
|
|
|
2166
2768
|
navigate_page: onCdp(SHARED_TOOLS.navigate_page),
|
|
2167
2769
|
wait_for: onCdp(SHARED_TOOLS.wait_for),
|
|
2168
2770
|
evaluate_script: onCdp(SHARED_TOOLS.evaluate_script),
|
|
2771
|
+
list_cookies: onCdp(SHARED_TOOLS.list_cookies),
|
|
2772
|
+
set_cookie: onCdp(SHARED_TOOLS.set_cookie),
|
|
2773
|
+
delete_cookies: onCdp(SHARED_TOOLS.delete_cookies),
|
|
2169
2774
|
take_snapshot: onCdp(SHARED_TOOLS.take_snapshot),
|
|
2170
2775
|
click: onCdp(SHARED_TOOLS.click),
|
|
2171
2776
|
hover: onCdp(SHARED_TOOLS.hover),
|
|
@@ -2191,7 +2796,10 @@ var TOOLS = {
|
|
|
2191
2796
|
lighthouse_audit: lighthouseAudit,
|
|
2192
2797
|
mock_request: mockRequest,
|
|
2193
2798
|
list_mocks: listMocks,
|
|
2194
|
-
clear_mocks: clearMocks
|
|
2799
|
+
clear_mocks: clearMocks,
|
|
2800
|
+
claim_page: onCdp(LEASE_TOOLS.claim_page),
|
|
2801
|
+
release_page: onCdp(LEASE_TOOLS.release_page),
|
|
2802
|
+
list_leases: onCdp(LEASE_TOOLS.list_leases)
|
|
2195
2803
|
};
|
|
2196
2804
|
var TOOL_NAMES = Object.keys(TOOLS);
|
|
2197
2805
|
|
|
@@ -2699,6 +3307,37 @@ function buildSnapshotFunctionSource() {
|
|
|
2699
3307
|
var MAX_NAME_LENGTH = 120;
|
|
2700
3308
|
|
|
2701
3309
|
// src/bidi/driver.ts
|
|
3310
|
+
function normalizeBidiCookie(c) {
|
|
3311
|
+
const value = c.value.type === "base64" ? Buffer.from(c.value.value, "base64").toString("utf8") : c.value.value;
|
|
3312
|
+
const hasExpiry = typeof c.expiry === "number";
|
|
3313
|
+
return {
|
|
3314
|
+
name: c.name,
|
|
3315
|
+
value,
|
|
3316
|
+
domain: c.domain,
|
|
3317
|
+
path: c.path,
|
|
3318
|
+
expires: hasExpiry ? c.expiry : -1,
|
|
3319
|
+
size: typeof c.size === "number" ? c.size : Buffer.byteLength(`${c.name}${value}`, "utf8"),
|
|
3320
|
+
httpOnly: c.httpOnly === true,
|
|
3321
|
+
secure: c.secure === true,
|
|
3322
|
+
sameSite: c.sameSite ?? "default",
|
|
3323
|
+
session: !hasExpiry
|
|
3324
|
+
};
|
|
3325
|
+
}
|
|
3326
|
+
function hostFromUrl(url, toolName) {
|
|
3327
|
+
if (url === undefined || url === "") {
|
|
3328
|
+
throw driverError2("unsupported", `${toolName} on Firefox needs a 'domain': BiDi's storage cookie calls have no 'url' parameter, and no url was given to derive one from.`);
|
|
3329
|
+
}
|
|
3330
|
+
let host = "";
|
|
3331
|
+
try {
|
|
3332
|
+
host = new URL(url).hostname;
|
|
3333
|
+
} catch {
|
|
3334
|
+
throw driverError2("unsupported", `${toolName} could not parse the url '${url}' to derive the 'domain' BiDi requires. Pass 'domain' explicitly.`);
|
|
3335
|
+
}
|
|
3336
|
+
if (host === "") {
|
|
3337
|
+
throw driverError2("unsupported", `${toolName} cannot derive a 'domain' from '${url}': that url has no host. Pass 'domain' explicitly.`);
|
|
3338
|
+
}
|
|
3339
|
+
return host;
|
|
3340
|
+
}
|
|
2702
3341
|
function driverError2(code, message, data) {
|
|
2703
3342
|
return Object.assign(new Error(message), { code, ...data !== undefined ? { data } : {} });
|
|
2704
3343
|
}
|
|
@@ -2950,12 +3589,17 @@ async function safeTitle(conn, contextId) {
|
|
|
2950
3589
|
async function pageInfoOf(conn, ctx) {
|
|
2951
3590
|
return { id: ctx.context, url: ctx.url, title: await safeTitle(conn, ctx.context) };
|
|
2952
3591
|
}
|
|
2953
|
-
async function resolveContext(conn, selector) {
|
|
3592
|
+
async function resolveContext(conn, selector, opts = {}) {
|
|
2954
3593
|
const { contexts } = await conn.send("browsingContext.getTree", {}).catch((e) => {
|
|
2955
3594
|
throw mapBidiError(e);
|
|
2956
3595
|
});
|
|
2957
3596
|
if (!contexts.length)
|
|
2958
3597
|
throw driverError2("no-such-target", "no browsing contexts available");
|
|
3598
|
+
const hit = await pickContext(conn, contexts, selector);
|
|
3599
|
+
await assertLeaseOk("firefox", hit.context, { lease: opts.lease, url: hit.url });
|
|
3600
|
+
return hit;
|
|
3601
|
+
}
|
|
3602
|
+
async function pickContext(conn, contexts, selector) {
|
|
2959
3603
|
if (selector === undefined || selector === "active")
|
|
2960
3604
|
return contexts[0];
|
|
2961
3605
|
if (selector.startsWith("index:")) {
|
|
@@ -3302,6 +3946,49 @@ class BidiPageDriver {
|
|
|
3302
3946
|
}
|
|
3303
3947
|
return this.handleOneDialog(accept, promptText, opts?.timeoutMs ?? 15000);
|
|
3304
3948
|
}
|
|
3949
|
+
async getCookies() {
|
|
3950
|
+
try {
|
|
3951
|
+
const { cookies } = await this.conn.send("storage.getCookies", { partition: { type: "context", context: this.contextId } });
|
|
3952
|
+
return (cookies ?? []).map(normalizeBidiCookie);
|
|
3953
|
+
} catch (e) {
|
|
3954
|
+
throw mapBidiError(e);
|
|
3955
|
+
}
|
|
3956
|
+
}
|
|
3957
|
+
async setCookie(params) {
|
|
3958
|
+
const domain = params.domain ?? hostFromUrl(params.url, "set_cookie");
|
|
3959
|
+
try {
|
|
3960
|
+
await this.conn.send("storage.setCookie", {
|
|
3961
|
+
cookie: {
|
|
3962
|
+
name: params.name,
|
|
3963
|
+
value: { type: "string", value: params.value },
|
|
3964
|
+
domain,
|
|
3965
|
+
...params.path !== undefined ? { path: params.path } : {},
|
|
3966
|
+
...params.expires !== undefined ? { expiry: params.expires } : {},
|
|
3967
|
+
...params.httpOnly !== undefined ? { httpOnly: params.httpOnly } : {},
|
|
3968
|
+
...params.secure !== undefined ? { secure: params.secure } : {},
|
|
3969
|
+
...params.sameSite !== undefined ? { sameSite: params.sameSite } : {}
|
|
3970
|
+
},
|
|
3971
|
+
partition: { type: "context", context: this.contextId }
|
|
3972
|
+
});
|
|
3973
|
+
} catch (e) {
|
|
3974
|
+
throw mapBidiError(e);
|
|
3975
|
+
}
|
|
3976
|
+
}
|
|
3977
|
+
async deleteCookies(filter) {
|
|
3978
|
+
const domain = filter.domain ?? hostFromUrl(filter.url, "delete_cookies");
|
|
3979
|
+
try {
|
|
3980
|
+
await this.conn.send("storage.deleteCookies", {
|
|
3981
|
+
filter: {
|
|
3982
|
+
name: filter.name,
|
|
3983
|
+
domain,
|
|
3984
|
+
...filter.path !== undefined ? { path: filter.path } : {}
|
|
3985
|
+
},
|
|
3986
|
+
partition: { type: "context", context: this.contextId }
|
|
3987
|
+
});
|
|
3988
|
+
} catch (e) {
|
|
3989
|
+
throw mapBidiError(e);
|
|
3990
|
+
}
|
|
3991
|
+
}
|
|
3305
3992
|
async startBodyCapture() {
|
|
3306
3993
|
let collector;
|
|
3307
3994
|
try {
|
|
@@ -3476,7 +4163,7 @@ import { spawn as spawn2 } from "node:child_process";
|
|
|
3476
4163
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
3477
4164
|
import { createConnection, createServer } from "node:net";
|
|
3478
4165
|
import { tmpdir } from "node:os";
|
|
3479
|
-
import { join as
|
|
4166
|
+
import { join as join6 } from "node:path";
|
|
3480
4167
|
function resolveBinary() {
|
|
3481
4168
|
const candidates = [
|
|
3482
4169
|
process.env.FIREFOX_BIN,
|
|
@@ -3550,7 +4237,7 @@ async function launchFirefox(opts = {}) {
|
|
|
3550
4237
|
const binary = resolveBinary();
|
|
3551
4238
|
const port = await pickPort(opts.port);
|
|
3552
4239
|
const isThrowaway = opts.profilePath === undefined;
|
|
3553
|
-
const profilePath = opts.profilePath ?? mkdtempSync(
|
|
4240
|
+
const profilePath = opts.profilePath ?? mkdtempSync(join6(tmpdir(), "cdp-toolkit-ff-profile-"));
|
|
3554
4241
|
const args = ["--profile", profilePath, "--remote-debugging-port", String(port), "--no-remote"];
|
|
3555
4242
|
if (opts.headless)
|
|
3556
4243
|
args.push("--headless");
|
|
@@ -3815,7 +4502,8 @@ var BIDI_ONLY_TOOLS = {
|
|
|
3815
4502
|
// src/firefox-tools.ts
|
|
3816
4503
|
var FIREFOX_TOOLS = {
|
|
3817
4504
|
...SHARED_TOOLS,
|
|
3818
|
-
...BIDI_ONLY_TOOLS
|
|
4505
|
+
...BIDI_ONLY_TOOLS,
|
|
4506
|
+
...LEASE_TOOLS
|
|
3819
4507
|
};
|
|
3820
4508
|
|
|
3821
4509
|
// src/cli.ts
|
|
@@ -3840,6 +4528,8 @@ zero behavior change for existing users). Firefox is LAUNCHED per invocation (it
|
|
|
3840
4528
|
to), used for exactly one tool call, then disposed before the process exits.
|
|
3841
4529
|
|
|
3842
4530
|
Target selector grammar: active | index:N | url:<substr> | title:<substr> | <targetId>
|
|
4531
|
+
Leases: pass --lease <token> to act on a tab another agent claimed with claim_page (MCP only).
|
|
4532
|
+
Run list_leases to see who holds what; release_page --lease <token> gives a lease back.
|
|
3843
4533
|
Run with --list to print every tool name available under the selected backend.
|
|
3844
4534
|
Run with --capabilities to see, per tool, whether it is available and (if not) which capability
|
|
3845
4535
|
the selected backend is missing.`;
|
|
@@ -3943,28 +4633,38 @@ async function main() {
|
|
|
3943
4633
|
`);
|
|
3944
4634
|
return 1;
|
|
3945
4635
|
}
|
|
3946
|
-
if (
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
`);
|
|
3951
|
-
return 0;
|
|
3952
|
-
}
|
|
3953
|
-
const neutralFn = FIREFOX_TOOLS[parsed.tool];
|
|
3954
|
-
if (!neutralFn) {
|
|
3955
|
-
process.stderr.write(`${JSON.stringify({ error: `tool '${parsed.tool}' has no Firefox implementation wired` })}
|
|
4636
|
+
if (parsed.tool === "claim_page") {
|
|
4637
|
+
process.stderr.write(`${JSON.stringify({
|
|
4638
|
+
error: "claim_page is not available from the CLI: a CLI invocation is one process per call, so the lease it claimed would be reclaimable immediately by the dead-pid rule. Claim from the cdp-toolkit MCP server, which is long lived, then pass the token to CLI calls with --lease <token>."
|
|
4639
|
+
})}
|
|
3956
4640
|
`);
|
|
3957
4641
|
return 1;
|
|
3958
4642
|
}
|
|
3959
|
-
const
|
|
3960
|
-
|
|
3961
|
-
|
|
3962
|
-
|
|
4643
|
+
const tool = parsed.tool;
|
|
4644
|
+
return withLeaseScope(leaseFromArgs(parsed.args), async () => {
|
|
4645
|
+
if (browser === "chrome") {
|
|
4646
|
+
const fn = TOOLS[tool];
|
|
4647
|
+
const result = await fn(parsed.args);
|
|
4648
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
3963
4649
|
`);
|
|
3964
|
-
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
|
|
4650
|
+
return 0;
|
|
4651
|
+
}
|
|
4652
|
+
const neutralFn = FIREFOX_TOOLS[tool];
|
|
4653
|
+
if (!neutralFn) {
|
|
4654
|
+
process.stderr.write(`${JSON.stringify({ error: `tool '${tool}' has no Firefox implementation wired` })}
|
|
4655
|
+
`);
|
|
4656
|
+
return 1;
|
|
4657
|
+
}
|
|
4658
|
+
const session = await startFirefoxSession();
|
|
4659
|
+
try {
|
|
4660
|
+
const result = await neutralFn(session.driver, parsed.args);
|
|
4661
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
4662
|
+
`);
|
|
4663
|
+
return 0;
|
|
4664
|
+
} finally {
|
|
4665
|
+
await session.dispose();
|
|
4666
|
+
}
|
|
4667
|
+
});
|
|
3968
4668
|
}
|
|
3969
4669
|
main().then((code) => process.exit(code)).catch((err) => {
|
|
3970
4670
|
const message = err instanceof Error ? err.message : String(err);
|