cdp-toolkit 2.1.0 → 2.2.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 +22 -0
- package/README.md +18 -4
- package/dist/bidi/mux-daemon.js +3773 -0
- package/dist/cli.js +6457 -5690
- package/dist/index.js +275 -210
- package/dist/mcp.js +8128 -7361
- package/package.json +2 -1
- package/src/bidi/driver.ts +329 -30
- package/src/bidi/mux-daemon.ts +122 -0
- package/src/bidi/mux.ts +610 -0
- package/src/bidi/session-lease.ts +95 -3
- package/src/manifest.ts +1 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,3773 @@
|
|
|
1
|
+
var __esm = (fn, res, err) => () => {
|
|
2
|
+
if (fn)
|
|
3
|
+
try {
|
|
4
|
+
res = fn(fn = 0);
|
|
5
|
+
} catch (e) {
|
|
6
|
+
err = [e];
|
|
7
|
+
}
|
|
8
|
+
if (err)
|
|
9
|
+
throw err[0];
|
|
10
|
+
return res;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// src/leases.ts
|
|
14
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
15
|
+
import { randomBytes } from "node:crypto";
|
|
16
|
+
import { mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
function leaseDir() {
|
|
19
|
+
return process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
20
|
+
}
|
|
21
|
+
function leaseTtlMs() {
|
|
22
|
+
const raw = Number(process.env.CDP_LEASE_TTL_MS);
|
|
23
|
+
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_LEASE_TTL_MS;
|
|
24
|
+
}
|
|
25
|
+
function markLongLivedProcess(value = true) {
|
|
26
|
+
longLived = value;
|
|
27
|
+
}
|
|
28
|
+
function requireLease() {
|
|
29
|
+
if (!longLived)
|
|
30
|
+
return false;
|
|
31
|
+
const raw = (process.env.CDP_REQUIRE_LEASE ?? "").trim().toLowerCase();
|
|
32
|
+
return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
|
|
33
|
+
}
|
|
34
|
+
function defaultLabel() {
|
|
35
|
+
return `pid-${process.pid}`;
|
|
36
|
+
}
|
|
37
|
+
function safeId(id) {
|
|
38
|
+
return id.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
39
|
+
}
|
|
40
|
+
function leaseFile(backend, targetId) {
|
|
41
|
+
return join(leaseDir(), `lease-${backend}-${safeId(targetId)}.json`);
|
|
42
|
+
}
|
|
43
|
+
function mintToken(backend, targetId, nonce) {
|
|
44
|
+
return `${backend}:${targetId}:${nonce}`;
|
|
45
|
+
}
|
|
46
|
+
function tokenParts(token) {
|
|
47
|
+
if (typeof token !== "string")
|
|
48
|
+
return;
|
|
49
|
+
const first = token.indexOf(":");
|
|
50
|
+
const last = token.lastIndexOf(":");
|
|
51
|
+
if (first <= 0 || last <= first)
|
|
52
|
+
return;
|
|
53
|
+
const backend = token.slice(0, first);
|
|
54
|
+
if (backend !== "chrome" && backend !== "firefox")
|
|
55
|
+
return;
|
|
56
|
+
const targetId = token.slice(first + 1, last);
|
|
57
|
+
const nonce = token.slice(last + 1);
|
|
58
|
+
if (!targetId.length || !/^[0-9a-f]{24}$/.test(nonce))
|
|
59
|
+
return;
|
|
60
|
+
return { backend, targetId, nonce };
|
|
61
|
+
}
|
|
62
|
+
async function readLease(backend, targetId) {
|
|
63
|
+
let raw;
|
|
64
|
+
try {
|
|
65
|
+
raw = await readFile(leaseFile(backend, targetId), "utf8");
|
|
66
|
+
} catch (err) {
|
|
67
|
+
if (err.code === "ENOENT")
|
|
68
|
+
return;
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const rec = JSON.parse(raw);
|
|
73
|
+
if (typeof rec?.nonce === "string" && typeof rec?.pid === "number")
|
|
74
|
+
return rec;
|
|
75
|
+
} catch {}
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
function isPidAlive(pid) {
|
|
79
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
80
|
+
return false;
|
|
81
|
+
try {
|
|
82
|
+
process.kill(pid, 0);
|
|
83
|
+
return true;
|
|
84
|
+
} catch (err) {
|
|
85
|
+
return err.code === "EPERM";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function staleReason(rec, opts) {
|
|
89
|
+
if (!isPidAlive(rec.pid))
|
|
90
|
+
return "dead-pid";
|
|
91
|
+
if (opts.now - rec.lastUsedAt > rec.ttlMs)
|
|
92
|
+
return "expired";
|
|
93
|
+
if (opts.liveIds && !opts.liveIds.includes(rec.targetId))
|
|
94
|
+
return "target-gone";
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
async function claimLease(backend, targetId, opts) {
|
|
98
|
+
const now = opts.now ?? Date.now();
|
|
99
|
+
await mkdir(leaseDir(), { recursive: true });
|
|
100
|
+
for (let attempt = 0;attempt < 3; attempt++) {
|
|
101
|
+
const record = {
|
|
102
|
+
backend,
|
|
103
|
+
targetId,
|
|
104
|
+
nonce: randomBytes(12).toString("hex"),
|
|
105
|
+
pid: process.pid,
|
|
106
|
+
label: opts.label,
|
|
107
|
+
createdAt: now,
|
|
108
|
+
lastUsedAt: now,
|
|
109
|
+
ttlMs: opts.ttlMs ?? leaseTtlMs(),
|
|
110
|
+
auto: opts.auto === true
|
|
111
|
+
};
|
|
112
|
+
try {
|
|
113
|
+
await writeFile(leaseFile(backend, targetId), JSON.stringify(record), { flag: "wx" });
|
|
114
|
+
return { token: mintToken(backend, targetId, record.nonce), record };
|
|
115
|
+
} catch (err) {
|
|
116
|
+
if (err.code !== "EEXIST")
|
|
117
|
+
throw err;
|
|
118
|
+
}
|
|
119
|
+
const existing = await readLease(backend, targetId);
|
|
120
|
+
if (!existing)
|
|
121
|
+
continue;
|
|
122
|
+
const stale = staleReason(existing, { now, liveIds: opts.liveIds });
|
|
123
|
+
if (!stale) {
|
|
124
|
+
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);
|
|
125
|
+
}
|
|
126
|
+
await unlink(leaseFile(backend, targetId)).catch(() => {
|
|
127
|
+
return;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
throw new LeaseConflictError(`target '${targetId}' could not be claimed after 3 attempts: another process is claiming and releasing it concurrently`, targetId);
|
|
131
|
+
}
|
|
132
|
+
async function releaseLease(token) {
|
|
133
|
+
const parts = tokenParts(token);
|
|
134
|
+
if (!parts)
|
|
135
|
+
return { released: false };
|
|
136
|
+
const rec = await readLease(parts.backend, parts.targetId);
|
|
137
|
+
if (!rec || rec.nonce !== parts.nonce)
|
|
138
|
+
return { released: false };
|
|
139
|
+
await unlink(leaseFile(parts.backend, parts.targetId)).catch(() => {
|
|
140
|
+
return;
|
|
141
|
+
});
|
|
142
|
+
return { released: true };
|
|
143
|
+
}
|
|
144
|
+
async function releaseLeaseFor(backend, targetId) {
|
|
145
|
+
if (!await readLease(backend, targetId))
|
|
146
|
+
return { released: false };
|
|
147
|
+
await unlink(leaseFile(backend, targetId)).catch(() => {
|
|
148
|
+
return;
|
|
149
|
+
});
|
|
150
|
+
return { released: true };
|
|
151
|
+
}
|
|
152
|
+
function withLeaseScope(lease, fn) {
|
|
153
|
+
return leaseScope.run(lease, fn);
|
|
154
|
+
}
|
|
155
|
+
function currentLease() {
|
|
156
|
+
return leaseScope.getStore();
|
|
157
|
+
}
|
|
158
|
+
function leaseFromArgs(args) {
|
|
159
|
+
if (!args || typeof args !== "object")
|
|
160
|
+
return;
|
|
161
|
+
const lease = args.lease;
|
|
162
|
+
return typeof lease === "string" && lease.length > 0 ? lease : undefined;
|
|
163
|
+
}
|
|
164
|
+
async function touchLease(rec, now = Date.now()) {
|
|
165
|
+
const current = await readLease(rec.backend, rec.targetId).catch(() => {
|
|
166
|
+
return;
|
|
167
|
+
});
|
|
168
|
+
if (!current || current.nonce !== rec.nonce)
|
|
169
|
+
return;
|
|
170
|
+
await writeFile(leaseFile(current.backend, current.targetId), JSON.stringify({ ...current, lastUsedAt: now }), "utf8").catch(() => {
|
|
171
|
+
return;
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
function describeTarget(targetId, ctx) {
|
|
175
|
+
const url = ctx.url ? ` (url ${ctx.url})` : ctx.title ? ` (title ${JSON.stringify(ctx.title)})` : "";
|
|
176
|
+
return `target '${targetId}'${url}`;
|
|
177
|
+
}
|
|
178
|
+
async function assertLeaseOk(backend, targetId, ctx = {}) {
|
|
179
|
+
const token = ctx.lease ?? currentLease();
|
|
180
|
+
const now = ctx.now ?? Date.now();
|
|
181
|
+
const rec = await readLease(backend, targetId);
|
|
182
|
+
const held = rec && !staleReason(rec, { now, liveIds: ctx.liveIds }) ? rec : undefined;
|
|
183
|
+
const where = describeTarget(targetId, ctx);
|
|
184
|
+
if (token === undefined) {
|
|
185
|
+
if (!held) {
|
|
186
|
+
if (requireLease()) {
|
|
187
|
+
await claimLease(backend, targetId, { label: defaultLabel(), ttlMs: leaseTtlMs(), auto: true, now });
|
|
188
|
+
}
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (held.auto === true && held.pid === process.pid) {
|
|
192
|
+
await touchLease(held, now);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
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);
|
|
196
|
+
}
|
|
197
|
+
const parts = tokenParts(token);
|
|
198
|
+
if (!parts) {
|
|
199
|
+
throw new LeaseConflictError(`malformed lease token for ${where}. Pass back the token claim_page returned, never a constructed one.`, targetId);
|
|
200
|
+
}
|
|
201
|
+
if (parts.backend !== backend || parts.targetId !== targetId) {
|
|
202
|
+
if (!held) {
|
|
203
|
+
if (requireLease()) {
|
|
204
|
+
await claimLease(backend, targetId, { label: defaultLabel(), ttlMs: leaseTtlMs(), auto: true, now });
|
|
205
|
+
}
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
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);
|
|
209
|
+
}
|
|
210
|
+
if (!held) {
|
|
211
|
+
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);
|
|
212
|
+
}
|
|
213
|
+
if (parts.nonce !== held.nonce) {
|
|
214
|
+
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);
|
|
215
|
+
}
|
|
216
|
+
await touchLease(held, now);
|
|
217
|
+
}
|
|
218
|
+
async function listLeases(opts = {}) {
|
|
219
|
+
const now = opts.now ?? Date.now();
|
|
220
|
+
let names = [];
|
|
221
|
+
try {
|
|
222
|
+
names = await readdir(leaseDir());
|
|
223
|
+
} catch (err) {
|
|
224
|
+
if (err.code === "ENOENT")
|
|
225
|
+
return [];
|
|
226
|
+
throw err;
|
|
227
|
+
}
|
|
228
|
+
const out = [];
|
|
229
|
+
for (const name of names) {
|
|
230
|
+
const parts = LEASE_FILE_RE.exec(name);
|
|
231
|
+
if (!parts)
|
|
232
|
+
continue;
|
|
233
|
+
const fromName = {
|
|
234
|
+
backend: parts[1],
|
|
235
|
+
targetId: parts[2],
|
|
236
|
+
label: "",
|
|
237
|
+
pid: 0,
|
|
238
|
+
createdAt: 0,
|
|
239
|
+
lastUsedAt: 0,
|
|
240
|
+
ttlMs: 0,
|
|
241
|
+
pidAlive: false,
|
|
242
|
+
stale: false
|
|
243
|
+
};
|
|
244
|
+
let rec;
|
|
245
|
+
try {
|
|
246
|
+
rec = JSON.parse(await readFile(join(leaseDir(), name), "utf8"));
|
|
247
|
+
} catch (err) {
|
|
248
|
+
const code = err.code;
|
|
249
|
+
if (code === "ENOENT")
|
|
250
|
+
continue;
|
|
251
|
+
out.push({ ...fromName, unreadable: code ?? "unparseable" });
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (typeof rec?.nonce !== "string" || typeof rec?.pid !== "number") {
|
|
255
|
+
out.push({ ...fromName, unreadable: "unparseable" });
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
out.push({
|
|
259
|
+
backend: rec.backend,
|
|
260
|
+
targetId: rec.targetId,
|
|
261
|
+
label: rec.label,
|
|
262
|
+
pid: rec.pid,
|
|
263
|
+
createdAt: rec.createdAt,
|
|
264
|
+
lastUsedAt: rec.lastUsedAt,
|
|
265
|
+
ttlMs: rec.ttlMs,
|
|
266
|
+
pidAlive: isPidAlive(rec.pid),
|
|
267
|
+
stale: staleReason(rec, {
|
|
268
|
+
now,
|
|
269
|
+
liveIds: opts.liveBackend === undefined || opts.liveBackend === rec.backend ? opts.liveIds : undefined
|
|
270
|
+
})
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
return out;
|
|
274
|
+
}
|
|
275
|
+
var DEFAULT_LEASE_TTL_MS = 900000, longLived = false, LeaseConflictError, leaseScope, LEASE_FILE_RE;
|
|
276
|
+
var init_leases = __esm(() => {
|
|
277
|
+
LeaseConflictError = class LeaseConflictError extends Error {
|
|
278
|
+
targetId;
|
|
279
|
+
holder;
|
|
280
|
+
constructor(message, targetId, holder) {
|
|
281
|
+
super(message);
|
|
282
|
+
this.targetId = targetId;
|
|
283
|
+
this.holder = holder;
|
|
284
|
+
this.name = "LeaseConflictError";
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
leaseScope = new AsyncLocalStorage;
|
|
288
|
+
LEASE_FILE_RE = /^lease-(chrome|firefox)-(.+)\.json$/;
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// src/bidi/session-lease.ts
|
|
292
|
+
import { mkdir as mkdir2, readFile as readFile2, rename, unlink as unlink2, writeFile as writeFile2 } from "node:fs/promises";
|
|
293
|
+
import { join as join2 } from "node:path";
|
|
294
|
+
function safeEndpoint(endpoint) {
|
|
295
|
+
return endpoint.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
296
|
+
}
|
|
297
|
+
function sessionSlotFile(endpoint) {
|
|
298
|
+
return join2(leaseDir(), `ff-session-${safeEndpoint(endpoint)}.json`);
|
|
299
|
+
}
|
|
300
|
+
function muxDaemonLogFile(endpoint) {
|
|
301
|
+
return join2(leaseDir(), `ff-mux-${safeEndpoint(endpoint)}.log`);
|
|
302
|
+
}
|
|
303
|
+
function sessionWaitMs() {
|
|
304
|
+
const raw = Number(process.env.CDP_FIREFOX_SESSION_WAIT_MS);
|
|
305
|
+
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_SESSION_WAIT_MS;
|
|
306
|
+
}
|
|
307
|
+
async function readSessionSlot(endpoint) {
|
|
308
|
+
let raw;
|
|
309
|
+
try {
|
|
310
|
+
raw = await readFile2(sessionSlotFile(endpoint), "utf8");
|
|
311
|
+
} catch (err) {
|
|
312
|
+
if (err.code === "ENOENT")
|
|
313
|
+
return;
|
|
314
|
+
throw err;
|
|
315
|
+
}
|
|
316
|
+
try {
|
|
317
|
+
const rec = JSON.parse(raw);
|
|
318
|
+
if (typeof rec?.pid === "number" && typeof rec?.endpoint === "string" && typeof rec?.label === "string") {
|
|
319
|
+
return rec;
|
|
320
|
+
}
|
|
321
|
+
} catch {}
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
async function acquireSessionSlot(endpoint, opts) {
|
|
325
|
+
const waitMs = opts.waitMs ?? sessionWaitMs();
|
|
326
|
+
const pollMs = opts.pollMs !== undefined && opts.pollMs > 0 ? opts.pollMs : DEFAULT_POLL_MS;
|
|
327
|
+
const createdAt = opts.now ?? Date.now();
|
|
328
|
+
const file = sessionSlotFile(endpoint);
|
|
329
|
+
const record = {
|
|
330
|
+
endpoint,
|
|
331
|
+
pid: process.pid,
|
|
332
|
+
label: opts.label,
|
|
333
|
+
createdAt
|
|
334
|
+
};
|
|
335
|
+
if (opts.marionettePort !== undefined)
|
|
336
|
+
record.marionettePort = opts.marionettePort;
|
|
337
|
+
const handle = { endpoint, pid: process.pid, createdAt };
|
|
338
|
+
const payload = JSON.stringify(record);
|
|
339
|
+
await mkdir2(leaseDir(), { recursive: true });
|
|
340
|
+
const deadline = Date.now() + waitMs;
|
|
341
|
+
const maxAttempts = Math.max(100, Math.ceil(waitMs / pollMs) + 100);
|
|
342
|
+
let attempts = 0;
|
|
343
|
+
let lastLiveHolder;
|
|
344
|
+
while (true) {
|
|
345
|
+
if (attempts >= maxAttempts)
|
|
346
|
+
throw new SessionSlotBusyError(endpoint, lastLiveHolder);
|
|
347
|
+
attempts++;
|
|
348
|
+
try {
|
|
349
|
+
await writeFile2(file, payload, { flag: "wx" });
|
|
350
|
+
return { handle };
|
|
351
|
+
} catch (err) {
|
|
352
|
+
if (err.code !== "EEXIST")
|
|
353
|
+
throw err;
|
|
354
|
+
}
|
|
355
|
+
const holder = await readSessionSlot(endpoint);
|
|
356
|
+
if (!holder)
|
|
357
|
+
continue;
|
|
358
|
+
if (!isPidAlive(holder.pid)) {
|
|
359
|
+
await unlink2(file).catch(() => {
|
|
360
|
+
return;
|
|
361
|
+
});
|
|
362
|
+
try {
|
|
363
|
+
await writeFile2(file, payload, { flag: "wx" });
|
|
364
|
+
return { handle, staleHolder: holder };
|
|
365
|
+
} catch (err) {
|
|
366
|
+
if (err.code !== "EEXIST")
|
|
367
|
+
throw err;
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (typeof holder.muxEndpoint === "string" && holder.muxEndpoint.length > 0 && process.env.CDP_FIREFOX_MUX !== "off") {
|
|
372
|
+
return { joinMux: { endpoint: holder.muxEndpoint, holder } };
|
|
373
|
+
}
|
|
374
|
+
lastLiveHolder = holder;
|
|
375
|
+
const remaining = deadline - Date.now();
|
|
376
|
+
if (remaining <= 0)
|
|
377
|
+
throw new SessionSlotBusyError(endpoint, holder);
|
|
378
|
+
await sleep(Math.min(pollMs, remaining));
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
async function advertiseMux(handle, muxEndpoint) {
|
|
382
|
+
const current = await readSessionSlot(handle.endpoint);
|
|
383
|
+
if (!current)
|
|
384
|
+
return { advertised: false };
|
|
385
|
+
if (current.pid !== process.pid || current.createdAt !== handle.createdAt)
|
|
386
|
+
return { advertised: false };
|
|
387
|
+
const file = sessionSlotFile(handle.endpoint);
|
|
388
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
389
|
+
const next = { ...current, muxEndpoint };
|
|
390
|
+
try {
|
|
391
|
+
await writeFile2(tmp, JSON.stringify(next), "utf8");
|
|
392
|
+
await rename(tmp, file);
|
|
393
|
+
} catch {
|
|
394
|
+
await unlink2(tmp).catch(() => {
|
|
395
|
+
return;
|
|
396
|
+
});
|
|
397
|
+
return { advertised: false };
|
|
398
|
+
}
|
|
399
|
+
return { advertised: true };
|
|
400
|
+
}
|
|
401
|
+
async function releaseSessionSlot(handle) {
|
|
402
|
+
const current = await readSessionSlot(handle.endpoint);
|
|
403
|
+
if (!current)
|
|
404
|
+
return { released: false };
|
|
405
|
+
if (current.pid !== process.pid || current.createdAt !== handle.createdAt)
|
|
406
|
+
return { released: false };
|
|
407
|
+
await unlink2(sessionSlotFile(handle.endpoint)).catch(() => {
|
|
408
|
+
return;
|
|
409
|
+
});
|
|
410
|
+
return { released: true };
|
|
411
|
+
}
|
|
412
|
+
function sleep(ms) {
|
|
413
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
414
|
+
}
|
|
415
|
+
var DEFAULT_SESSION_WAIT_MS = 1e4, DEFAULT_POLL_MS = 250, SessionSlotBusyError;
|
|
416
|
+
var init_session_lease = __esm(() => {
|
|
417
|
+
init_leases();
|
|
418
|
+
SessionSlotBusyError = class SessionSlotBusyError extends Error {
|
|
419
|
+
endpoint;
|
|
420
|
+
holderPid;
|
|
421
|
+
holderLabel;
|
|
422
|
+
constructor(endpoint, holder) {
|
|
423
|
+
const who = holder ? `'${holder.label}' (pid ${holder.pid})` : "another live process";
|
|
424
|
+
super(`Firefox BiDi session slot for endpoint '${endpoint}' is held by ${who}. ` + `Firefox serves exactly ONE WebDriver-BiDi session per browser instance, and this slot is held ` + `by a LIVE process, so it cannot be taken. Wait for that process to release it and retry, or point ` + `this server at a different endpoint/browser.`);
|
|
425
|
+
this.name = "SessionSlotBusyError";
|
|
426
|
+
this.endpoint = endpoint;
|
|
427
|
+
if (holder) {
|
|
428
|
+
this.holderPid = holder.pid;
|
|
429
|
+
this.holderLabel = holder.label;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
// src/driver.ts
|
|
436
|
+
import { mkdir as mkdir3, readFile as readFile3, unlink as unlink3, writeFile as writeFile3 } from "node:fs/promises";
|
|
437
|
+
import { join as join3 } from "node:path";
|
|
438
|
+
function isDriverError(e) {
|
|
439
|
+
return e instanceof Error && typeof e.code === "string";
|
|
440
|
+
}
|
|
441
|
+
function interpolatePoints(from, to, steps) {
|
|
442
|
+
const out = [];
|
|
443
|
+
for (let i = 1;i < steps; i++) {
|
|
444
|
+
out.push({ x: from.x + (to.x - from.x) * i / steps, y: from.y + (to.y - from.y) * i / steps });
|
|
445
|
+
}
|
|
446
|
+
out.push({ x: to.x, y: to.y });
|
|
447
|
+
return out;
|
|
448
|
+
}
|
|
449
|
+
function isTiledCapture(delivery) {
|
|
450
|
+
return delivery.tiled === true;
|
|
451
|
+
}
|
|
452
|
+
function viewportRecordFile(scheme, targetId) {
|
|
453
|
+
return join3(leaseDir(), `viewport-${scheme}-${targetId.replace(/[^A-Za-z0-9._-]/g, "_")}.json`);
|
|
454
|
+
}
|
|
455
|
+
async function readViewportRecord(scheme, targetId) {
|
|
456
|
+
try {
|
|
457
|
+
const raw = JSON.parse(await readFile3(viewportRecordFile(scheme, targetId), "utf8"));
|
|
458
|
+
if (typeof raw?.width !== "number" || typeof raw?.height !== "number")
|
|
459
|
+
return;
|
|
460
|
+
return {
|
|
461
|
+
width: raw.width,
|
|
462
|
+
height: raw.height,
|
|
463
|
+
...typeof raw.deviceScaleFactor === "number" ? { deviceScaleFactor: raw.deviceScaleFactor } : {},
|
|
464
|
+
...typeof raw.mobile === "boolean" ? { mobile: raw.mobile } : {}
|
|
465
|
+
};
|
|
466
|
+
} catch {
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
async function writeViewportRecord(scheme, targetId, rec) {
|
|
471
|
+
try {
|
|
472
|
+
await mkdir3(leaseDir(), { recursive: true });
|
|
473
|
+
await writeFile3(viewportRecordFile(scheme, targetId), JSON.stringify({ scheme, targetId, ...rec }), "utf8");
|
|
474
|
+
} catch {}
|
|
475
|
+
}
|
|
476
|
+
function renderRestoreFailureNote(size, why) {
|
|
477
|
+
return `— AND the ${size.width}x${size.height} render-size override could NOT be restored (${why}), so this page is STILL ` + `emulated at ${size.width}x${size.height}: put it back with emulate{width,height} or emulate{clearOverrides:true}.`;
|
|
478
|
+
}
|
|
479
|
+
async function clearViewportRecord(scheme, targetId) {
|
|
480
|
+
try {
|
|
481
|
+
await unlink3(viewportRecordFile(scheme, targetId));
|
|
482
|
+
} catch {}
|
|
483
|
+
}
|
|
484
|
+
var UID_STAMP_ATTR = "data-cdp-uid", LEGACY_NUMERIC_UID, REQUIRED_CAPABILITIES;
|
|
485
|
+
var init_driver = __esm(() => {
|
|
486
|
+
init_leases();
|
|
487
|
+
LEGACY_NUMERIC_UID = /^[0-9]+$/;
|
|
488
|
+
REQUIRED_CAPABILITIES = {
|
|
489
|
+
performance_start_trace: ["trace.performance"],
|
|
490
|
+
performance_stop_trace: ["trace.performance"],
|
|
491
|
+
performance_analyze_insight: ["trace.performance"],
|
|
492
|
+
performance_trace: ["trace.performance"],
|
|
493
|
+
take_heapsnapshot: ["heap.snapshot"],
|
|
494
|
+
lighthouse_audit: ["audit.lighthouse"],
|
|
495
|
+
start_screen_recording: ["capture.screencast"],
|
|
496
|
+
stop_screen_recording: ["capture.screencast"],
|
|
497
|
+
mock_request: ["network.intercept"],
|
|
498
|
+
list_mocks: ["network.intercept"],
|
|
499
|
+
clear_mocks: ["network.intercept"],
|
|
500
|
+
dispatch_mouse: ["input.raw"],
|
|
501
|
+
wait_for_download: ["browser.downloads"],
|
|
502
|
+
grant_permissions: ["browser.permissions"]
|
|
503
|
+
};
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
// src/origins.ts
|
|
507
|
+
import { mkdir as mkdir4, readFile as readFile4, readdir as readdir2, unlink as unlink4, writeFile as writeFile4 } from "node:fs/promises";
|
|
508
|
+
import { join as join4 } from "node:path";
|
|
509
|
+
function safeId2(id) {
|
|
510
|
+
return id.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
511
|
+
}
|
|
512
|
+
function originFile(backend, targetId) {
|
|
513
|
+
return join4(leaseDir(), `origin-${backend}-${safeId2(targetId)}.json`);
|
|
514
|
+
}
|
|
515
|
+
async function recordOrigin(backend, targetId, opts = {}) {
|
|
516
|
+
const record = {
|
|
517
|
+
backend,
|
|
518
|
+
targetId,
|
|
519
|
+
label: typeof opts.label === "string" && opts.label.length ? opts.label : defaultLabel(),
|
|
520
|
+
pid: process.pid,
|
|
521
|
+
createdAt: opts.now ?? Date.now()
|
|
522
|
+
};
|
|
523
|
+
const file = originFile(backend, targetId);
|
|
524
|
+
try {
|
|
525
|
+
await mkdir4(leaseDir(), { recursive: true });
|
|
526
|
+
try {
|
|
527
|
+
await writeFile4(file, JSON.stringify(record), { flag: "wx" });
|
|
528
|
+
return;
|
|
529
|
+
} catch (err) {
|
|
530
|
+
if (err.code !== "EEXIST")
|
|
531
|
+
throw err;
|
|
532
|
+
}
|
|
533
|
+
await unlink4(file).catch(() => {
|
|
534
|
+
return;
|
|
535
|
+
});
|
|
536
|
+
await writeFile4(file, JSON.stringify(record), { flag: "wx" });
|
|
537
|
+
} catch {}
|
|
538
|
+
}
|
|
539
|
+
async function readOrigin(backend, targetId) {
|
|
540
|
+
let raw;
|
|
541
|
+
try {
|
|
542
|
+
raw = await readFile4(originFile(backend, targetId), "utf8");
|
|
543
|
+
} catch {
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
try {
|
|
547
|
+
const rec = JSON.parse(raw);
|
|
548
|
+
if (typeof rec?.targetId === "string" && typeof rec?.label === "string" && typeof rec?.createdAt === "number")
|
|
549
|
+
return rec;
|
|
550
|
+
} catch {}
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
async function listOrigins(opts = {}) {
|
|
554
|
+
let names = [];
|
|
555
|
+
try {
|
|
556
|
+
names = await readdir2(leaseDir());
|
|
557
|
+
} catch {
|
|
558
|
+
return [];
|
|
559
|
+
}
|
|
560
|
+
const live = opts.liveIds === undefined ? undefined : new Set(opts.liveIds.map((id) => safeId2(id)));
|
|
561
|
+
const out = [];
|
|
562
|
+
for (const name of names) {
|
|
563
|
+
const parts = ORIGIN_FILE_RE.exec(name);
|
|
564
|
+
if (!parts)
|
|
565
|
+
continue;
|
|
566
|
+
const backend = parts[1];
|
|
567
|
+
const nameId = parts[2];
|
|
568
|
+
const scoped = live !== undefined && (opts.liveBackend === undefined || opts.liveBackend === backend);
|
|
569
|
+
if (scoped && !live.has(nameId)) {
|
|
570
|
+
await unlink4(join4(leaseDir(), name)).catch(() => {
|
|
571
|
+
return;
|
|
572
|
+
});
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
const fromName = { backend, targetId: nameId, label: "", pid: 0, createdAt: 0 };
|
|
576
|
+
let rec;
|
|
577
|
+
try {
|
|
578
|
+
rec = JSON.parse(await readFile4(join4(leaseDir(), name), "utf8"));
|
|
579
|
+
} catch (err) {
|
|
580
|
+
const code = err.code;
|
|
581
|
+
if (code === "ENOENT")
|
|
582
|
+
continue;
|
|
583
|
+
out.push({ ...fromName, unreadable: code ?? "unparseable" });
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
if (typeof rec?.targetId !== "string" || typeof rec?.label !== "string" || typeof rec?.createdAt !== "number") {
|
|
587
|
+
out.push({ ...fromName, unreadable: "unparseable" });
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
out.push({ backend: rec.backend, targetId: rec.targetId, label: rec.label, pid: rec.pid, createdAt: rec.createdAt });
|
|
591
|
+
}
|
|
592
|
+
return out;
|
|
593
|
+
}
|
|
594
|
+
async function originIndex(backend, liveIds) {
|
|
595
|
+
const rows = await listOrigins({ liveIds, liveBackend: backend }).catch(() => []);
|
|
596
|
+
const index = new Map;
|
|
597
|
+
for (const row of rows) {
|
|
598
|
+
if (row.backend !== backend)
|
|
599
|
+
continue;
|
|
600
|
+
index.set(row.targetId, row);
|
|
601
|
+
}
|
|
602
|
+
return index;
|
|
603
|
+
}
|
|
604
|
+
async function newTrackedPage(driver, backend, opts = {}) {
|
|
605
|
+
const page = await driver.newPage(opts.url);
|
|
606
|
+
await recordOrigin(backend, page.id, { label: opts.label });
|
|
607
|
+
return page;
|
|
608
|
+
}
|
|
609
|
+
async function resolveLiveLabel(backend, label, liveIds) {
|
|
610
|
+
const live = new Set(liveIds);
|
|
611
|
+
const [origins, leases] = await Promise.all([originIndex(backend), listLeases({ liveBackend: backend })]);
|
|
612
|
+
const knownLabels = new Set;
|
|
613
|
+
const matchIds = new Set;
|
|
614
|
+
for (const rec of origins.values()) {
|
|
615
|
+
if (rec.unreadable !== undefined || !live.has(rec.targetId))
|
|
616
|
+
continue;
|
|
617
|
+
knownLabels.add(rec.label);
|
|
618
|
+
if (rec.label === label)
|
|
619
|
+
matchIds.add(rec.targetId);
|
|
620
|
+
}
|
|
621
|
+
for (const rec of leases) {
|
|
622
|
+
if (rec.backend !== backend || rec.unreadable !== undefined || !live.has(rec.targetId))
|
|
623
|
+
continue;
|
|
624
|
+
knownLabels.add(rec.label);
|
|
625
|
+
if (rec.label === label)
|
|
626
|
+
matchIds.add(rec.targetId);
|
|
627
|
+
}
|
|
628
|
+
return { matchIds: [...matchIds], knownLabels: [...knownLabels].sort() };
|
|
629
|
+
}
|
|
630
|
+
var ORIGIN_FILE_RE;
|
|
631
|
+
var init_origins = __esm(() => {
|
|
632
|
+
init_leases();
|
|
633
|
+
ORIGIN_FILE_RE = /^origin-(chrome|firefox)-(.+)\.json$/;
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
// src/workers.ts
|
|
637
|
+
function isWorkerSelector(selector) {
|
|
638
|
+
return typeof selector === "string" && selector.startsWith(WORKER_SELECTOR_PREFIX);
|
|
639
|
+
}
|
|
640
|
+
function workerNeedle(selector) {
|
|
641
|
+
return selector.slice(WORKER_SELECTOR_PREFIX.length);
|
|
642
|
+
}
|
|
643
|
+
function isWorkerTargetType(type) {
|
|
644
|
+
return type !== undefined && WORKER_TARGET_TYPES.has(type);
|
|
645
|
+
}
|
|
646
|
+
function resolveWorkerTargets(targets, needle) {
|
|
647
|
+
const liveWorkers = targets.filter((t) => isWorkerTargetType(t.type));
|
|
648
|
+
return {
|
|
649
|
+
matches: needle === "" ? [] : liveWorkers.filter((t) => t.url.includes(needle)),
|
|
650
|
+
liveWorkers
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
function workerWakeMisuseMessage(kind) {
|
|
654
|
+
if (kind === "not-a-worker-selector") {
|
|
655
|
+
return "'wake' only applies to a target of the form 'worker:<substring>'. A page is never asleep, and a bare " + "worker target id cannot be woken either: Chrome destroys the target when an MV3 worker is evicted and " + "mints a NEW id when it restarts, so only the url-substring form can name a worker that is not running.";
|
|
656
|
+
}
|
|
657
|
+
return "'wake' only applies when a capture is being started (reload:true, or includeBody with url). Reading an " + "existing buffer cannot use a woken worker: a restarted MV3 worker gets a NEW target id, and the buffer is " + "keyed by id, so the wake would hand back an empty capture. Re-run with reload:true to record a fresh one.";
|
|
658
|
+
}
|
|
659
|
+
function workerBufferReadMissMessage(needle, liveWorkers) {
|
|
660
|
+
const running = liveWorkers.length ? ` (running workers: ${urlList(liveWorkers)})` : " (no workers are running)";
|
|
661
|
+
return `no running worker whose url contains '${needle}'${running}. ` + "An MV3 extension service worker is idle-evicted after seconds of inactivity and then appears in NO target " + "listing, so this is usually a sleeping worker rather than a wrong substring. This call only READS a buffer " + "recorded earlier, and that buffer is keyed by the worker's target id, which Chrome re-mints on restart — so " + "re-run with reload:true, which starts the worker and records a fresh capture window.";
|
|
662
|
+
}
|
|
663
|
+
function urlList(workers) {
|
|
664
|
+
return workers.map((w) => w.url).join(", ");
|
|
665
|
+
}
|
|
666
|
+
function workerAmbiguityMessage(needle, matches) {
|
|
667
|
+
return `'worker:${needle}' matches more than one running worker: ` + matches.map((m) => `${m.url} (${m.id})`).join(", ") + ". Narrow the substring, or pass the worker's target id directly.";
|
|
668
|
+
}
|
|
669
|
+
function workerMissMessage(needle, opts) {
|
|
670
|
+
const running = opts.liveWorkers?.length ? ` (running workers: ${urlList(opts.liveWorkers)})` : " (no workers are running)";
|
|
671
|
+
if (!opts.wakeAttempted) {
|
|
672
|
+
return `no running worker whose url contains '${needle}'${running}. ` + "An MV3 extension service worker is idle-evicted after seconds of inactivity and then appears in NO target " + "listing, so this is usually a sleeping worker rather than a wrong substring. You passed wake:false; " + "wake:true (the default) asks Chrome to start it first.";
|
|
673
|
+
}
|
|
674
|
+
const scopes = opts.registeredScopes?.length ? ` Registered service-worker scopes in this browser: ${opts.registeredScopes.join(", ")}.` : " No service-worker registrations were reported by this browser at all.";
|
|
675
|
+
return `no worker whose url contains '${needle}' could be reached${running}. ` + `A start was requested (wake:true) and no matching worker appeared.${scopes} ` + "If the extension is installed but its worker never starts, check the worker script for a top-level throw.";
|
|
676
|
+
}
|
|
677
|
+
var WORKER_SELECTOR_PREFIX = "worker:", WORKER_CAPABLE_TOOLS, WORKER_TARGET_TYPES, WORKER_EMPTY_NEEDLE_MESSAGE = "'worker:' needs a substring of the worker's url, e.g. worker:<extension-id> or worker:background.js", WORKER_SELECTOR_UNSUPPORTED_MESSAGE, WORKER_SELECTOR_PAGE_ONLY_MESSAGE;
|
|
678
|
+
var init_workers = __esm(() => {
|
|
679
|
+
WORKER_CAPABLE_TOOLS = [
|
|
680
|
+
"evaluate_script",
|
|
681
|
+
"list_network_requests",
|
|
682
|
+
"get_network_request",
|
|
683
|
+
"list_console_messages"
|
|
684
|
+
];
|
|
685
|
+
WORKER_TARGET_TYPES = new Set(["service_worker", "shared_worker"]);
|
|
686
|
+
WORKER_SELECTOR_UNSUPPORTED_MESSAGE = "target 'worker:<substring>' selects a service/shared worker, which this backend cannot address " + "(WebDriver BiDi has no extension service-worker target; Capability 'worker.targets' is Chrome-only). " + "Use --browser chrome to evaluate in, or record console/network from, an extension's background worker.";
|
|
687
|
+
WORKER_SELECTOR_PAGE_ONLY_MESSAGE = "target 'worker:<substring>' selects a service/shared worker, and this tool is page-only " + "(closing, selecting or releasing a worker is meaningless). The tools that accept a worker: target are " + `${WORKER_CAPABLE_TOOLS.join(", ")}.`;
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
// src/activity.ts
|
|
691
|
+
function dispatchKey(backend, targetId) {
|
|
692
|
+
return `${backend}:${targetId}`;
|
|
693
|
+
}
|
|
694
|
+
function recordDispatch(backend, targetId, now = Date.now()) {
|
|
695
|
+
const key = dispatchKey(backend, targetId);
|
|
696
|
+
dispatchLog.delete(key);
|
|
697
|
+
dispatchLog.set(key, now);
|
|
698
|
+
while (dispatchLog.size > DISPATCH_LOG_LIMIT) {
|
|
699
|
+
const oldest = dispatchLog.keys().next();
|
|
700
|
+
if (oldest.done)
|
|
701
|
+
break;
|
|
702
|
+
dispatchLog.delete(oldest.value);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
async function sendInput(conn, backend, targetId, method, params = {}) {
|
|
706
|
+
if (!NON_SYNTHESIZING_INPUT_METHODS.has(method))
|
|
707
|
+
recordDispatch(backend, targetId);
|
|
708
|
+
return conn.send(method, params);
|
|
709
|
+
}
|
|
710
|
+
function lastDispatchAt(backend, targetId) {
|
|
711
|
+
return dispatchLog.get(dispatchKey(backend, targetId));
|
|
712
|
+
}
|
|
713
|
+
function isHumanAttributed(beaconTs, dispatchAt, windowMs = DISPATCH_ATTRIBUTION_WINDOW_MS) {
|
|
714
|
+
if (typeof beaconTs !== "number" || !Number.isFinite(beaconTs))
|
|
715
|
+
return false;
|
|
716
|
+
return beaconTs > (dispatchAt ?? 0) + windowMs;
|
|
717
|
+
}
|
|
718
|
+
function humanActiveMs(beaconTs, dispatchAt, now = Date.now(), windowMs = DISPATCH_ATTRIBUTION_WINDOW_MS) {
|
|
719
|
+
if (!isHumanAttributed(beaconTs, dispatchAt, windowMs))
|
|
720
|
+
return null;
|
|
721
|
+
return Math.max(0, now - beaconTs);
|
|
722
|
+
}
|
|
723
|
+
function contentionWarning(ms, opts) {
|
|
724
|
+
if (!opts.takeover)
|
|
725
|
+
return;
|
|
726
|
+
if (ms === null || ms >= CONTENTION_WINDOW_MS)
|
|
727
|
+
return;
|
|
728
|
+
return `a person appears to be using this tab right now: input not dispatched by this server landed ${ms}ms ago. ` + `The claim SUCCEEDED and you hold the lease — this is a warning, not a refusal. ` + `Driving the tab now will fight them for the keyboard and mouse. ` + `Consider claim_page with no target to work in your own tab instead, or ask before continuing.`;
|
|
729
|
+
}
|
|
730
|
+
function beaconSupported(driver) {
|
|
731
|
+
return typeof driver.installActivityBeacon === "function" && typeof driver.readActivityBeacon === "function";
|
|
732
|
+
}
|
|
733
|
+
async function installBeacon(driver, targetId) {
|
|
734
|
+
if (typeof driver.installActivityBeacon !== "function")
|
|
735
|
+
return false;
|
|
736
|
+
return driver.installActivityBeacon(targetId).catch(() => false);
|
|
737
|
+
}
|
|
738
|
+
async function readHumanActiveMs(driver, backend, targetId, now = Date.now()) {
|
|
739
|
+
if (typeof driver.readActivityBeacon !== "function")
|
|
740
|
+
return null;
|
|
741
|
+
const beaconTs = await driver.readActivityBeacon(targetId).catch(() => null);
|
|
742
|
+
return humanActiveMs(beaconTs, lastDispatchAt(backend, targetId), now);
|
|
743
|
+
}
|
|
744
|
+
function rendererProbeSupported(driver) {
|
|
745
|
+
return typeof driver.probeRenderer === "function";
|
|
746
|
+
}
|
|
747
|
+
async function probeRendererActivity(driver, backend, targetId, now = Date.now()) {
|
|
748
|
+
if (typeof driver.probeRenderer !== "function")
|
|
749
|
+
return { responsive: false };
|
|
750
|
+
const { responsive, beaconTs } = await driver.probeRenderer(targetId, RENDERER_PROBE_TIMEOUT_MS).catch(() => ({ responsive: false, beaconTs: null }));
|
|
751
|
+
const ms = humanActiveMs(beaconTs, lastDispatchAt(backend, targetId), now);
|
|
752
|
+
return ms === null ? { responsive } : { responsive, humanActiveMs: ms };
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
class BeaconSessions {
|
|
756
|
+
close;
|
|
757
|
+
limit;
|
|
758
|
+
live = new Map;
|
|
759
|
+
constructor(close, limit = 32) {
|
|
760
|
+
this.close = close;
|
|
761
|
+
this.limit = limit;
|
|
762
|
+
}
|
|
763
|
+
get(key) {
|
|
764
|
+
return this.live.get(key);
|
|
765
|
+
}
|
|
766
|
+
set(key, value) {
|
|
767
|
+
const previous = this.live.get(key);
|
|
768
|
+
if (previous !== undefined) {
|
|
769
|
+
this.live.delete(key);
|
|
770
|
+
this.safeClose(previous);
|
|
771
|
+
}
|
|
772
|
+
this.live.set(key, value);
|
|
773
|
+
while (this.live.size > this.limit) {
|
|
774
|
+
const oldest = this.live.keys().next();
|
|
775
|
+
if (oldest.done)
|
|
776
|
+
break;
|
|
777
|
+
this.drop(oldest.value);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
drop(key) {
|
|
781
|
+
const value = this.live.get(key);
|
|
782
|
+
if (value === undefined)
|
|
783
|
+
return;
|
|
784
|
+
this.live.delete(key);
|
|
785
|
+
this.safeClose(value);
|
|
786
|
+
}
|
|
787
|
+
clear() {
|
|
788
|
+
for (const key of [...this.live.keys()])
|
|
789
|
+
this.drop(key);
|
|
790
|
+
}
|
|
791
|
+
get size() {
|
|
792
|
+
return this.live.size;
|
|
793
|
+
}
|
|
794
|
+
safeClose(value) {
|
|
795
|
+
try {
|
|
796
|
+
this.close(value);
|
|
797
|
+
} catch {}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
var BEACON_DATA_GLOBAL = "__cdpToolkitLastInput", BEACON_INSTALLED_GLOBAL = "__cdpToolkitBeacon", BEACON_EVENTS, BEACON_BODY, BEACON_SOURCE, BEACON_FUNCTION_DECLARATION, BEACON_READ_EXPRESSION, DISPATCH_ATTRIBUTION_WINDOW_MS = 1500, dispatchLog, DISPATCH_LOG_LIMIT = 512, NON_SYNTHESIZING_INPUT_METHODS, CONTENTION_WINDOW_MS = 30000, RENDERER_PROBE_TIMEOUT_MS = 500, RENDERER_PROBE_EXPRESSION;
|
|
801
|
+
var init_activity = __esm(() => {
|
|
802
|
+
BEACON_EVENTS = ["pointerdown", "mousedown", "keydown", "wheel", "touchstart"];
|
|
803
|
+
BEACON_BODY = `
|
|
804
|
+
if (!window.${BEACON_INSTALLED_GLOBAL}) {
|
|
805
|
+
window.${BEACON_INSTALLED_GLOBAL} = true;
|
|
806
|
+
var mark = function () { window.${BEACON_DATA_GLOBAL} = Date.now(); };
|
|
807
|
+
var events = ${JSON.stringify([...BEACON_EVENTS])};
|
|
808
|
+
for (var i = 0; i < events.length; i++) {
|
|
809
|
+
window.addEventListener(events[i], mark, { capture: true, passive: true });
|
|
810
|
+
}
|
|
811
|
+
}`;
|
|
812
|
+
BEACON_SOURCE = `(function () {${BEACON_BODY}
|
|
813
|
+
})();`;
|
|
814
|
+
BEACON_FUNCTION_DECLARATION = `function () {${BEACON_BODY}
|
|
815
|
+
}`;
|
|
816
|
+
BEACON_READ_EXPRESSION = `(typeof window.${BEACON_DATA_GLOBAL} === "number" ? window.${BEACON_DATA_GLOBAL} : null)`;
|
|
817
|
+
dispatchLog = new Map;
|
|
818
|
+
NON_SYNTHESIZING_INPUT_METHODS = new Set(["Input.setInterceptDrags", "Input.setIgnoreInputEvents"]);
|
|
819
|
+
RENDERER_PROBE_EXPRESSION = `[1, ${BEACON_READ_EXPRESSION}]`;
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
// src/bidi/client.ts
|
|
823
|
+
class BidiConnection {
|
|
824
|
+
wsUrl;
|
|
825
|
+
opts;
|
|
826
|
+
ws;
|
|
827
|
+
nextId = 0;
|
|
828
|
+
sessId;
|
|
829
|
+
caps;
|
|
830
|
+
closed = false;
|
|
831
|
+
refCount = 0;
|
|
832
|
+
pending = new Map;
|
|
833
|
+
listeners = new Map;
|
|
834
|
+
constructor(wsUrl, opts = {}) {
|
|
835
|
+
this.wsUrl = wsUrl;
|
|
836
|
+
this.opts = opts;
|
|
837
|
+
}
|
|
838
|
+
get sessionId() {
|
|
839
|
+
return this.sessId;
|
|
840
|
+
}
|
|
841
|
+
get capabilities() {
|
|
842
|
+
return this.caps;
|
|
843
|
+
}
|
|
844
|
+
get holders() {
|
|
845
|
+
return this.refCount;
|
|
846
|
+
}
|
|
847
|
+
get isOpen() {
|
|
848
|
+
return !this.closed && this.ws !== undefined;
|
|
849
|
+
}
|
|
850
|
+
connect() {
|
|
851
|
+
return new Promise((resolve, reject) => {
|
|
852
|
+
const ws = new WebSocket(this.wsUrl);
|
|
853
|
+
this.ws = ws;
|
|
854
|
+
const openTimer = setTimeout(() => reject(new BidiError(`connect timeout: ${this.wsUrl}`)), this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
855
|
+
ws.onopen = () => {
|
|
856
|
+
clearTimeout(openTimer);
|
|
857
|
+
resolve(this);
|
|
858
|
+
};
|
|
859
|
+
ws.onerror = (e) => {
|
|
860
|
+
clearTimeout(openTimer);
|
|
861
|
+
const msg = e?.message ?? "websocket error";
|
|
862
|
+
if (this.pending.size === 0)
|
|
863
|
+
reject(new BidiError(msg));
|
|
864
|
+
this.rejectAll(new BidiError(msg));
|
|
865
|
+
};
|
|
866
|
+
ws.onclose = () => {
|
|
867
|
+
this.closed = true;
|
|
868
|
+
this.rejectAll(new BidiError("connection closed"));
|
|
869
|
+
};
|
|
870
|
+
ws.onmessage = (ev) => this.onMessage(String(ev.data));
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
async newSession(capabilities = {}) {
|
|
874
|
+
const result = await this.send("session.new", { capabilities });
|
|
875
|
+
this.sessId = result.sessionId;
|
|
876
|
+
this.caps = result.capabilities;
|
|
877
|
+
return result.sessionId;
|
|
878
|
+
}
|
|
879
|
+
onMessage(raw) {
|
|
880
|
+
let msg;
|
|
881
|
+
try {
|
|
882
|
+
msg = JSON.parse(raw);
|
|
883
|
+
} catch {
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
if (msg.type === "success" || msg.type === "error") {
|
|
887
|
+
const id = msg.id;
|
|
888
|
+
if (typeof id !== "number" || !this.pending.has(id))
|
|
889
|
+
return;
|
|
890
|
+
const p = this.pending.get(id);
|
|
891
|
+
clearTimeout(p.timer);
|
|
892
|
+
this.pending.delete(id);
|
|
893
|
+
p.resolve(msg);
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
if (msg.type === "event") {
|
|
897
|
+
const set = this.listeners.get(msg.method);
|
|
898
|
+
if (set)
|
|
899
|
+
for (const h of set)
|
|
900
|
+
h(msg.params);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
rejectAll(err) {
|
|
904
|
+
for (const [, p] of this.pending) {
|
|
905
|
+
clearTimeout(p.timer);
|
|
906
|
+
p.reject(err);
|
|
907
|
+
}
|
|
908
|
+
this.pending.clear();
|
|
909
|
+
}
|
|
910
|
+
send(method, params, opts = {}) {
|
|
911
|
+
if (this.closed || !this.ws)
|
|
912
|
+
return Promise.reject(new BidiError("connection not open"));
|
|
913
|
+
const id = ++this.nextId;
|
|
914
|
+
const timeoutMs = opts.timeoutMs ?? this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
915
|
+
const payload = { id, method, params };
|
|
916
|
+
return new Promise((resolve, reject) => {
|
|
917
|
+
const start = Date.now();
|
|
918
|
+
const timer = setTimeout(() => {
|
|
919
|
+
this.pending.delete(id);
|
|
920
|
+
const elapsed = Date.now() - start;
|
|
921
|
+
reject(new BidiError(`'${method}' timed out after ${elapsed}ms`));
|
|
922
|
+
}, timeoutMs);
|
|
923
|
+
this.pending.set(id, {
|
|
924
|
+
method,
|
|
925
|
+
start,
|
|
926
|
+
timer,
|
|
927
|
+
resolve: (raw) => {
|
|
928
|
+
if (raw.type === "error")
|
|
929
|
+
reject(new BidiError(`${method}: ${raw.message}`, raw.error));
|
|
930
|
+
else
|
|
931
|
+
resolve(raw.result);
|
|
932
|
+
},
|
|
933
|
+
reject
|
|
934
|
+
});
|
|
935
|
+
this.ws.send(JSON.stringify(payload));
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
on(event, handler) {
|
|
939
|
+
let set = this.listeners.get(event);
|
|
940
|
+
if (!set) {
|
|
941
|
+
set = new Set;
|
|
942
|
+
this.listeners.set(event, set);
|
|
943
|
+
}
|
|
944
|
+
const first = set.size === 0;
|
|
945
|
+
set.add(handler);
|
|
946
|
+
if (first) {
|
|
947
|
+
this.send("session.subscribe", { events: [event] }).catch(() => {});
|
|
948
|
+
}
|
|
949
|
+
let removed = false;
|
|
950
|
+
return () => {
|
|
951
|
+
if (removed)
|
|
952
|
+
return;
|
|
953
|
+
removed = true;
|
|
954
|
+
const s = this.listeners.get(event);
|
|
955
|
+
s?.delete(handler);
|
|
956
|
+
if (s && s.size === 0) {
|
|
957
|
+
this.send("session.unsubscribe", { events: [event] }).catch(() => {});
|
|
958
|
+
}
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
waitFor(event, predicate, timeoutMs = this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS) {
|
|
962
|
+
return new Promise((resolve, reject) => {
|
|
963
|
+
const timer = setTimeout(() => {
|
|
964
|
+
off();
|
|
965
|
+
reject(new BidiError(`waitFor('${event}') timed out after ${timeoutMs}ms`));
|
|
966
|
+
}, timeoutMs);
|
|
967
|
+
const off = this.on(event, (params) => {
|
|
968
|
+
if (!predicate || predicate(params)) {
|
|
969
|
+
clearTimeout(timer);
|
|
970
|
+
off();
|
|
971
|
+
resolve(params);
|
|
972
|
+
}
|
|
973
|
+
});
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
retain() {
|
|
977
|
+
this.refCount++;
|
|
978
|
+
}
|
|
979
|
+
release() {
|
|
980
|
+
if (this.refCount > 0)
|
|
981
|
+
this.refCount--;
|
|
982
|
+
}
|
|
983
|
+
dispose() {
|
|
984
|
+
if (this.closed)
|
|
985
|
+
return;
|
|
986
|
+
this.closed = true;
|
|
987
|
+
this.rejectAll(new BidiError("connection disposed"));
|
|
988
|
+
try {
|
|
989
|
+
this.ws?.close();
|
|
990
|
+
} catch {}
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
async function connectBidiSessionUrl(wsUrl, opts = {}) {
|
|
994
|
+
const conn = new BidiConnection(wsUrl, { timeoutMs: opts.timeoutMs });
|
|
995
|
+
await conn.connect();
|
|
996
|
+
try {
|
|
997
|
+
await conn.newSession(opts.capabilities ?? {});
|
|
998
|
+
} catch (e) {
|
|
999
|
+
conn.dispose();
|
|
1000
|
+
throw e;
|
|
1001
|
+
}
|
|
1002
|
+
conn.retain();
|
|
1003
|
+
return conn;
|
|
1004
|
+
}
|
|
1005
|
+
var DEFAULT_TIMEOUT_MS, BidiError;
|
|
1006
|
+
var init_client = __esm(() => {
|
|
1007
|
+
DEFAULT_TIMEOUT_MS = Number(process.env.BIDI_TIMEOUT_MS ?? 15000);
|
|
1008
|
+
BidiError = class BidiError extends Error {
|
|
1009
|
+
code;
|
|
1010
|
+
data;
|
|
1011
|
+
constructor(message, code, data) {
|
|
1012
|
+
super(message);
|
|
1013
|
+
this.code = code;
|
|
1014
|
+
this.data = data;
|
|
1015
|
+
this.name = "BidiError";
|
|
1016
|
+
}
|
|
1017
|
+
};
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
// src/bidi/mux.ts
|
|
1021
|
+
import { createServer } from "node:net";
|
|
1022
|
+
import { createHash } from "node:crypto";
|
|
1023
|
+
function acceptKey(key) {
|
|
1024
|
+
return createHash("sha1").update(key + WS_GUID).digest("base64");
|
|
1025
|
+
}
|
|
1026
|
+
function encodeFrame(opcode, payload) {
|
|
1027
|
+
const len = payload.length;
|
|
1028
|
+
let header;
|
|
1029
|
+
if (len < 126) {
|
|
1030
|
+
header = Buffer.alloc(2);
|
|
1031
|
+
header[1] = len;
|
|
1032
|
+
} else if (len < 65536) {
|
|
1033
|
+
header = Buffer.alloc(4);
|
|
1034
|
+
header[1] = 126;
|
|
1035
|
+
header.writeUInt16BE(len, 2);
|
|
1036
|
+
} else {
|
|
1037
|
+
header = Buffer.alloc(10);
|
|
1038
|
+
header[1] = 127;
|
|
1039
|
+
header.writeBigUInt64BE(BigInt(len), 2);
|
|
1040
|
+
}
|
|
1041
|
+
header[0] = 128 | opcode;
|
|
1042
|
+
return Buffer.concat([header, payload]);
|
|
1043
|
+
}
|
|
1044
|
+
function encodeClose(code, reason) {
|
|
1045
|
+
const r = Buffer.from(reason, "utf8");
|
|
1046
|
+
const body = Buffer.alloc(2 + r.length);
|
|
1047
|
+
body.writeUInt16BE(code, 0);
|
|
1048
|
+
r.copy(body, 2);
|
|
1049
|
+
return encodeFrame(8, body);
|
|
1050
|
+
}
|
|
1051
|
+
function createWsServer(opts) {
|
|
1052
|
+
const host = opts.host ?? "127.0.0.1";
|
|
1053
|
+
const path = opts.path ?? "/session";
|
|
1054
|
+
const clients = new Set;
|
|
1055
|
+
const sockets = new Set;
|
|
1056
|
+
const server = createServer((socket) => {
|
|
1057
|
+
sockets.add(socket);
|
|
1058
|
+
socket.setNoDelay(true);
|
|
1059
|
+
let buf = Buffer.alloc(0);
|
|
1060
|
+
let upgraded = false;
|
|
1061
|
+
let fragments = [];
|
|
1062
|
+
let fragmentOpcode = -1;
|
|
1063
|
+
let dead = false;
|
|
1064
|
+
let client;
|
|
1065
|
+
const drop = () => {
|
|
1066
|
+
if (dead)
|
|
1067
|
+
return;
|
|
1068
|
+
dead = true;
|
|
1069
|
+
sockets.delete(socket);
|
|
1070
|
+
if (client) {
|
|
1071
|
+
clients.delete(client);
|
|
1072
|
+
try {
|
|
1073
|
+
client.onClose?.();
|
|
1074
|
+
} catch {}
|
|
1075
|
+
}
|
|
1076
|
+
};
|
|
1077
|
+
const dispatch = (payload) => {
|
|
1078
|
+
try {
|
|
1079
|
+
client?.onMessage?.(payload.toString("utf8"));
|
|
1080
|
+
} catch {}
|
|
1081
|
+
};
|
|
1082
|
+
function handshake() {
|
|
1083
|
+
const end = buf.indexOf(`\r
|
|
1084
|
+
\r
|
|
1085
|
+
`);
|
|
1086
|
+
if (end === -1) {
|
|
1087
|
+
if (buf.length > 16 * 1024) {
|
|
1088
|
+
socket.end(`HTTP/1.1 431 Request Header Fields Too Large\r
|
|
1089
|
+
Connection: close\r
|
|
1090
|
+
\r
|
|
1091
|
+
`);
|
|
1092
|
+
drop();
|
|
1093
|
+
return false;
|
|
1094
|
+
}
|
|
1095
|
+
return false;
|
|
1096
|
+
}
|
|
1097
|
+
const header = buf.subarray(0, end).toString("latin1");
|
|
1098
|
+
buf = buf.subarray(end + 4);
|
|
1099
|
+
const lines = header.split(`\r
|
|
1100
|
+
`);
|
|
1101
|
+
const reqPath = (lines[0] ?? "").split(" ")[1]?.split("?")[0] ?? "";
|
|
1102
|
+
const headers = new Map;
|
|
1103
|
+
for (const line of lines.slice(1)) {
|
|
1104
|
+
const i = line.indexOf(":");
|
|
1105
|
+
if (i > 0)
|
|
1106
|
+
headers.set(line.slice(0, i).trim().toLowerCase(), line.slice(i + 1).trim());
|
|
1107
|
+
}
|
|
1108
|
+
if (reqPath !== path) {
|
|
1109
|
+
socket.end(`HTTP/1.1 404 Not Found\r
|
|
1110
|
+
Connection: close\r
|
|
1111
|
+
\r
|
|
1112
|
+
`);
|
|
1113
|
+
drop();
|
|
1114
|
+
return false;
|
|
1115
|
+
}
|
|
1116
|
+
const key = headers.get("sec-websocket-key");
|
|
1117
|
+
if (!key || (headers.get("upgrade") ?? "").toLowerCase() !== "websocket") {
|
|
1118
|
+
socket.end(`HTTP/1.1 400 Bad Request\r
|
|
1119
|
+
Connection: close\r
|
|
1120
|
+
\r
|
|
1121
|
+
`);
|
|
1122
|
+
drop();
|
|
1123
|
+
return false;
|
|
1124
|
+
}
|
|
1125
|
+
socket.write(`HTTP/1.1 101 Switching Protocols\r
|
|
1126
|
+
` + `Upgrade: websocket\r
|
|
1127
|
+
` + `Connection: Upgrade\r
|
|
1128
|
+
` + `Sec-WebSocket-Accept: ${acceptKey(key)}\r
|
|
1129
|
+
\r
|
|
1130
|
+
`);
|
|
1131
|
+
upgraded = true;
|
|
1132
|
+
const c = {
|
|
1133
|
+
send(text) {
|
|
1134
|
+
if (dead || socket.destroyed)
|
|
1135
|
+
return;
|
|
1136
|
+
try {
|
|
1137
|
+
socket.write(encodeFrame(1, Buffer.from(text, "utf8")));
|
|
1138
|
+
} catch {}
|
|
1139
|
+
},
|
|
1140
|
+
close(code, reason = "") {
|
|
1141
|
+
if (dead || socket.destroyed) {
|
|
1142
|
+
drop();
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
try {
|
|
1146
|
+
socket.write(encodeClose(code, reason));
|
|
1147
|
+
} catch {}
|
|
1148
|
+
drop();
|
|
1149
|
+
try {
|
|
1150
|
+
socket.end();
|
|
1151
|
+
} catch {}
|
|
1152
|
+
}
|
|
1153
|
+
};
|
|
1154
|
+
client = c;
|
|
1155
|
+
clients.add(c);
|
|
1156
|
+
opts.onConnection(c);
|
|
1157
|
+
return true;
|
|
1158
|
+
}
|
|
1159
|
+
socket.on("data", (chunk) => {
|
|
1160
|
+
try {
|
|
1161
|
+
buf = buf.length === 0 ? chunk : Buffer.concat([buf, chunk]);
|
|
1162
|
+
if (!upgraded && !handshake())
|
|
1163
|
+
return;
|
|
1164
|
+
const c = client;
|
|
1165
|
+
if (!c)
|
|
1166
|
+
return;
|
|
1167
|
+
for (;; ) {
|
|
1168
|
+
if (dead)
|
|
1169
|
+
return;
|
|
1170
|
+
if (buf.length < 2)
|
|
1171
|
+
return;
|
|
1172
|
+
const b0 = buf[0];
|
|
1173
|
+
const b1 = buf[1];
|
|
1174
|
+
const fin = (b0 & 128) !== 0;
|
|
1175
|
+
const opcode = b0 & 15;
|
|
1176
|
+
const masked = (b1 & 128) !== 0;
|
|
1177
|
+
let len = b1 & 127;
|
|
1178
|
+
let off = 2;
|
|
1179
|
+
if (len === 126) {
|
|
1180
|
+
if (buf.length < off + 2)
|
|
1181
|
+
return;
|
|
1182
|
+
len = buf.readUInt16BE(off);
|
|
1183
|
+
off += 2;
|
|
1184
|
+
} else if (len === 127) {
|
|
1185
|
+
if (buf.length < off + 8)
|
|
1186
|
+
return;
|
|
1187
|
+
const big = buf.readBigUInt64BE(off);
|
|
1188
|
+
if (big > BigInt(MAX_MESSAGE_BYTES)) {
|
|
1189
|
+
c.close(1009, "message too big");
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
len = Number(big);
|
|
1193
|
+
off += 8;
|
|
1194
|
+
}
|
|
1195
|
+
if (len > MAX_MESSAGE_BYTES) {
|
|
1196
|
+
c.close(1009, "message too big");
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
let maskKey;
|
|
1200
|
+
if (masked) {
|
|
1201
|
+
if (buf.length < off + 4)
|
|
1202
|
+
return;
|
|
1203
|
+
maskKey = buf.subarray(off, off + 4);
|
|
1204
|
+
off += 4;
|
|
1205
|
+
}
|
|
1206
|
+
if (buf.length < off + len)
|
|
1207
|
+
return;
|
|
1208
|
+
const payload = Buffer.from(buf.subarray(off, off + len));
|
|
1209
|
+
buf = buf.subarray(off + len);
|
|
1210
|
+
if (maskKey) {
|
|
1211
|
+
for (let i = 0;i < payload.length; i++)
|
|
1212
|
+
payload[i] = payload[i] ^ maskKey[i % 4];
|
|
1213
|
+
}
|
|
1214
|
+
if (opcode === 8) {
|
|
1215
|
+
const code = payload.length >= 2 ? payload.readUInt16BE(0) : 1000;
|
|
1216
|
+
c.close(code === 1005 ? 1000 : code, "");
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
if (opcode === 9) {
|
|
1220
|
+
if (!socket.destroyed)
|
|
1221
|
+
socket.write(encodeFrame(10, payload));
|
|
1222
|
+
continue;
|
|
1223
|
+
}
|
|
1224
|
+
if (opcode === 10)
|
|
1225
|
+
continue;
|
|
1226
|
+
if (opcode === 2) {
|
|
1227
|
+
c.close(1003, "binary frames unsupported");
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
if (opcode === 1 || opcode === 0) {
|
|
1231
|
+
if (opcode === 1) {
|
|
1232
|
+
if (fragmentOpcode !== -1) {
|
|
1233
|
+
c.close(1002, "interleaved fragments");
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
if (fin) {
|
|
1237
|
+
dispatch(payload);
|
|
1238
|
+
continue;
|
|
1239
|
+
}
|
|
1240
|
+
fragmentOpcode = 1;
|
|
1241
|
+
fragments = [payload];
|
|
1242
|
+
continue;
|
|
1243
|
+
}
|
|
1244
|
+
if (fragmentOpcode === -1) {
|
|
1245
|
+
c.close(1002, "continuation without start");
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
fragments.push(payload);
|
|
1249
|
+
const total = fragments.reduce((n, f) => n + f.length, 0);
|
|
1250
|
+
if (total > MAX_MESSAGE_BYTES) {
|
|
1251
|
+
c.close(1009, "message too big");
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
if (fin) {
|
|
1255
|
+
const whole = Buffer.concat(fragments);
|
|
1256
|
+
fragments = [];
|
|
1257
|
+
fragmentOpcode = -1;
|
|
1258
|
+
dispatch(whole);
|
|
1259
|
+
}
|
|
1260
|
+
continue;
|
|
1261
|
+
}
|
|
1262
|
+
c.close(1002, "unsupported opcode");
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
} catch {
|
|
1266
|
+
try {
|
|
1267
|
+
client?.close(1002, "protocol error");
|
|
1268
|
+
} catch {}
|
|
1269
|
+
drop();
|
|
1270
|
+
}
|
|
1271
|
+
});
|
|
1272
|
+
socket.on("error", () => drop());
|
|
1273
|
+
socket.on("close", () => drop());
|
|
1274
|
+
});
|
|
1275
|
+
return new Promise((resolve, reject) => {
|
|
1276
|
+
server.once("error", reject);
|
|
1277
|
+
server.listen(opts.port ?? 0, host, () => {
|
|
1278
|
+
const addr = server.address();
|
|
1279
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
1280
|
+
let closing;
|
|
1281
|
+
resolve({
|
|
1282
|
+
port,
|
|
1283
|
+
clients,
|
|
1284
|
+
close() {
|
|
1285
|
+
if (closing)
|
|
1286
|
+
return closing;
|
|
1287
|
+
closing = new Promise((res) => {
|
|
1288
|
+
let done = false;
|
|
1289
|
+
const finish = () => {
|
|
1290
|
+
if (done)
|
|
1291
|
+
return;
|
|
1292
|
+
done = true;
|
|
1293
|
+
clearInterval(poll);
|
|
1294
|
+
res();
|
|
1295
|
+
};
|
|
1296
|
+
const poll = setInterval(() => {
|
|
1297
|
+
if (!server.listening)
|
|
1298
|
+
finish();
|
|
1299
|
+
}, 20);
|
|
1300
|
+
poll.unref?.();
|
|
1301
|
+
server.close(finish);
|
|
1302
|
+
server.once("close", finish);
|
|
1303
|
+
for (const s of sockets) {
|
|
1304
|
+
try {
|
|
1305
|
+
s.destroy();
|
|
1306
|
+
} catch {}
|
|
1307
|
+
}
|
|
1308
|
+
sockets.clear();
|
|
1309
|
+
});
|
|
1310
|
+
return closing;
|
|
1311
|
+
}
|
|
1312
|
+
});
|
|
1313
|
+
});
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
async function startBidiMux(upstream, opts = {}) {
|
|
1317
|
+
const host = opts.host ?? "127.0.0.1";
|
|
1318
|
+
const states = new Map;
|
|
1319
|
+
const subs = new Map;
|
|
1320
|
+
let sessionSeq = 0;
|
|
1321
|
+
let closed = false;
|
|
1322
|
+
let closing;
|
|
1323
|
+
const server = await createWsServer({
|
|
1324
|
+
host,
|
|
1325
|
+
port: opts.port ?? 0,
|
|
1326
|
+
path: "/session",
|
|
1327
|
+
onConnection: (client) => {
|
|
1328
|
+
states.set(client, { sessionId: `mux-${++sessionSeq}`, subscribed: new Set });
|
|
1329
|
+
client.onMessage = (raw) => void handleMessage(client, raw);
|
|
1330
|
+
client.onClose = () => releaseClient(client);
|
|
1331
|
+
}
|
|
1332
|
+
});
|
|
1333
|
+
const endpoint = `ws://${host}:${server.port}/session`;
|
|
1334
|
+
function send(client, obj) {
|
|
1335
|
+
client.send(JSON.stringify(obj));
|
|
1336
|
+
}
|
|
1337
|
+
function fanOut(event, params) {
|
|
1338
|
+
for (const [client, state] of states) {
|
|
1339
|
+
if (state.subscribed.has(event))
|
|
1340
|
+
send(client, { type: "event", method: event, params });
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
function subscribeOne(client, event) {
|
|
1344
|
+
const state = states.get(client);
|
|
1345
|
+
if (!state || state.subscribed.has(event))
|
|
1346
|
+
return;
|
|
1347
|
+
state.subscribed.add(event);
|
|
1348
|
+
const existing = subs.get(event);
|
|
1349
|
+
if (existing) {
|
|
1350
|
+
existing.count++;
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
const off = upstream.on(event, (params) => fanOut(event, params));
|
|
1354
|
+
subs.set(event, { count: 1, off });
|
|
1355
|
+
}
|
|
1356
|
+
function unsubscribeOne(client, event) {
|
|
1357
|
+
const state = states.get(client);
|
|
1358
|
+
if (!state || !state.subscribed.has(event))
|
|
1359
|
+
return;
|
|
1360
|
+
state.subscribed.delete(event);
|
|
1361
|
+
const entry = subs.get(event);
|
|
1362
|
+
if (!entry)
|
|
1363
|
+
return;
|
|
1364
|
+
entry.count--;
|
|
1365
|
+
if (entry.count <= 0) {
|
|
1366
|
+
subs.delete(event);
|
|
1367
|
+
try {
|
|
1368
|
+
entry.off();
|
|
1369
|
+
} catch {}
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
function releaseClient(client) {
|
|
1373
|
+
const state = states.get(client);
|
|
1374
|
+
if (!state)
|
|
1375
|
+
return;
|
|
1376
|
+
for (const event of [...state.subscribed])
|
|
1377
|
+
unsubscribeOne(client, event);
|
|
1378
|
+
states.delete(client);
|
|
1379
|
+
}
|
|
1380
|
+
function eventsOf(params) {
|
|
1381
|
+
const raw = params?.events;
|
|
1382
|
+
return Array.isArray(raw) ? raw.filter((e) => typeof e === "string") : [];
|
|
1383
|
+
}
|
|
1384
|
+
async function handleMessage(client, raw) {
|
|
1385
|
+
let msg;
|
|
1386
|
+
try {
|
|
1387
|
+
msg = JSON.parse(raw);
|
|
1388
|
+
} catch {
|
|
1389
|
+
client.close(1002, "invalid JSON");
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1392
|
+
const id = msg.id;
|
|
1393
|
+
const method = msg.method;
|
|
1394
|
+
if (typeof id !== "number" || typeof method !== "string") {
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
const params = msg.params;
|
|
1398
|
+
const state = states.get(client);
|
|
1399
|
+
if (method === "session.new") {
|
|
1400
|
+
send(client, {
|
|
1401
|
+
type: "success",
|
|
1402
|
+
id,
|
|
1403
|
+
result: { sessionId: state?.sessionId ?? `mux-${++sessionSeq}`, capabilities: upstream.capabilities }
|
|
1404
|
+
});
|
|
1405
|
+
return;
|
|
1406
|
+
}
|
|
1407
|
+
if (method === "session.end") {
|
|
1408
|
+
releaseClient(client);
|
|
1409
|
+
if (state)
|
|
1410
|
+
states.set(client, { sessionId: state.sessionId, subscribed: new Set });
|
|
1411
|
+
send(client, { type: "success", id, result: {} });
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
if (method === "session.subscribe" || method === "session.unsubscribe") {
|
|
1415
|
+
for (const event of eventsOf(params)) {
|
|
1416
|
+
if (method === "session.subscribe")
|
|
1417
|
+
subscribeOne(client, event);
|
|
1418
|
+
else
|
|
1419
|
+
unsubscribeOne(client, event);
|
|
1420
|
+
}
|
|
1421
|
+
send(client, { type: "success", id, result: {} });
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
if (method === "session.status") {
|
|
1425
|
+
send(client, { type: "success", id, result: { ready: false, message: "multiplexed" } });
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1428
|
+
try {
|
|
1429
|
+
const result = await upstream.send(method, params, {
|
|
1430
|
+
timeoutMs: FORWARD_TIMEOUT_MS
|
|
1431
|
+
});
|
|
1432
|
+
send(client, { type: "success", id, result });
|
|
1433
|
+
} catch (e) {
|
|
1434
|
+
const err = e;
|
|
1435
|
+
const message = err?.message ?? String(e);
|
|
1436
|
+
send(client, {
|
|
1437
|
+
type: "error",
|
|
1438
|
+
id,
|
|
1439
|
+
error: (err instanceof BidiError ? err.code : undefined) ?? "unknown error",
|
|
1440
|
+
message
|
|
1441
|
+
});
|
|
1442
|
+
if (/connection closed|connection disposed|connection not open/.test(message)) {
|
|
1443
|
+
closeAll();
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
const watchdog = setInterval(() => {
|
|
1448
|
+
if (!upstream.isOpen)
|
|
1449
|
+
closeAll();
|
|
1450
|
+
}, 250);
|
|
1451
|
+
watchdog.unref?.();
|
|
1452
|
+
function closeAll() {
|
|
1453
|
+
if (closing)
|
|
1454
|
+
return closing;
|
|
1455
|
+
closed = true;
|
|
1456
|
+
clearInterval(watchdog);
|
|
1457
|
+
closing = (async () => {
|
|
1458
|
+
for (const [client] of [...states]) {
|
|
1459
|
+
try {
|
|
1460
|
+
client.close(1001, "holder closing");
|
|
1461
|
+
} catch {}
|
|
1462
|
+
}
|
|
1463
|
+
states.clear();
|
|
1464
|
+
for (const [, entry] of subs) {
|
|
1465
|
+
try {
|
|
1466
|
+
entry.off();
|
|
1467
|
+
} catch {}
|
|
1468
|
+
}
|
|
1469
|
+
subs.clear();
|
|
1470
|
+
await server.close();
|
|
1471
|
+
})();
|
|
1472
|
+
return closing;
|
|
1473
|
+
}
|
|
1474
|
+
return {
|
|
1475
|
+
endpoint,
|
|
1476
|
+
clients() {
|
|
1477
|
+
return closed ? 0 : server.clients.size;
|
|
1478
|
+
},
|
|
1479
|
+
close() {
|
|
1480
|
+
return closeAll();
|
|
1481
|
+
}
|
|
1482
|
+
};
|
|
1483
|
+
}
|
|
1484
|
+
var WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11", MAX_MESSAGE_BYTES, FORWARD_TIMEOUT_MS = 120000;
|
|
1485
|
+
var init_mux = __esm(() => {
|
|
1486
|
+
init_client();
|
|
1487
|
+
MAX_MESSAGE_BYTES = 64 * 1024 * 1024;
|
|
1488
|
+
});
|
|
1489
|
+
|
|
1490
|
+
// src/bidi/marionette.ts
|
|
1491
|
+
import { Socket } from "node:net";
|
|
1492
|
+
function defaultMarionettePort() {
|
|
1493
|
+
const raw = Number(process.env.CDP_FIREFOX_MARIONETTE_PORT);
|
|
1494
|
+
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_MARIONETTE_PORT;
|
|
1495
|
+
}
|
|
1496
|
+
function encodeFrame2(json) {
|
|
1497
|
+
const body = Buffer.from(JSON.stringify(json), "utf8");
|
|
1498
|
+
const prefix = Buffer.from(`${body.length}:`, "ascii");
|
|
1499
|
+
return Buffer.concat([prefix, body]);
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
class FrameDecoder {
|
|
1503
|
+
buf = Buffer.alloc(0);
|
|
1504
|
+
push(chunk) {
|
|
1505
|
+
this.buf = this.buf.length === 0 ? chunk : Buffer.concat([this.buf, chunk]);
|
|
1506
|
+
}
|
|
1507
|
+
next() {
|
|
1508
|
+
const colon = this.buf.indexOf(COLON);
|
|
1509
|
+
if (colon === -1) {
|
|
1510
|
+
this.assertDigitsPrefix(this.buf.length);
|
|
1511
|
+
return;
|
|
1512
|
+
}
|
|
1513
|
+
if (colon === 0)
|
|
1514
|
+
throw new MarionetteProtocolError("empty frame length prefix");
|
|
1515
|
+
this.assertDigitsPrefix(colon);
|
|
1516
|
+
const len = Number.parseInt(this.buf.toString("ascii", 0, colon), 10);
|
|
1517
|
+
const start = colon + 1;
|
|
1518
|
+
const end = start + len;
|
|
1519
|
+
if (this.buf.length < end)
|
|
1520
|
+
return;
|
|
1521
|
+
const payload = this.buf.toString("utf8", start, end);
|
|
1522
|
+
this.buf = this.buf.subarray(end);
|
|
1523
|
+
try {
|
|
1524
|
+
return JSON.parse(payload);
|
|
1525
|
+
} catch {
|
|
1526
|
+
throw new MarionetteProtocolError(`frame body was not valid JSON (${len} bytes): ${payload.slice(0, 80)}`);
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
assertDigitsPrefix(upTo) {
|
|
1530
|
+
for (let i = 0;i < upTo; i++) {
|
|
1531
|
+
const b = this.buf[i];
|
|
1532
|
+
if (b < 48 || b > 57) {
|
|
1533
|
+
throw new MarionetteProtocolError(`non-digit byte 0x${b.toString(16)} in frame length prefix`);
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
function isSlotFreedReply(frame) {
|
|
1539
|
+
if (!Array.isArray(frame))
|
|
1540
|
+
return false;
|
|
1541
|
+
const err = frame[2];
|
|
1542
|
+
if (err === null)
|
|
1543
|
+
return true;
|
|
1544
|
+
if (typeof err === "object" && err !== null && err.error === "invalid session id") {
|
|
1545
|
+
return true;
|
|
1546
|
+
}
|
|
1547
|
+
return false;
|
|
1548
|
+
}
|
|
1549
|
+
function forceClearOrphanSession(marionettePort, opts = {}) {
|
|
1550
|
+
const timeoutMs = opts.timeoutMs !== undefined && Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0 ? opts.timeoutMs : DEFAULT_TIMEOUT_MS2;
|
|
1551
|
+
return new Promise((resolve) => {
|
|
1552
|
+
if (!Number.isFinite(marionettePort) || marionettePort <= 0 || marionettePort > 65535) {
|
|
1553
|
+
resolve(false);
|
|
1554
|
+
return;
|
|
1555
|
+
}
|
|
1556
|
+
const sock = new Socket;
|
|
1557
|
+
const decoder = new FrameDecoder;
|
|
1558
|
+
let settled = false;
|
|
1559
|
+
let handshakeSeen = false;
|
|
1560
|
+
let commandSent = false;
|
|
1561
|
+
const settle = (value) => {
|
|
1562
|
+
if (settled)
|
|
1563
|
+
return;
|
|
1564
|
+
settled = true;
|
|
1565
|
+
clearTimeout(timer);
|
|
1566
|
+
sock.removeAllListeners();
|
|
1567
|
+
sock.destroy();
|
|
1568
|
+
resolve(value);
|
|
1569
|
+
};
|
|
1570
|
+
const timer = setTimeout(() => settle(false), timeoutMs);
|
|
1571
|
+
if (typeof timer.unref === "function")
|
|
1572
|
+
timer.unref();
|
|
1573
|
+
sock.on("error", () => settle(false));
|
|
1574
|
+
sock.on("close", () => settle(false));
|
|
1575
|
+
sock.on("data", (chunk) => {
|
|
1576
|
+
if (settled)
|
|
1577
|
+
return;
|
|
1578
|
+
try {
|
|
1579
|
+
decoder.push(chunk);
|
|
1580
|
+
for (;; ) {
|
|
1581
|
+
const frame = decoder.next();
|
|
1582
|
+
if (frame === undefined)
|
|
1583
|
+
break;
|
|
1584
|
+
if (!handshakeSeen) {
|
|
1585
|
+
handshakeSeen = true;
|
|
1586
|
+
continue;
|
|
1587
|
+
}
|
|
1588
|
+
settle(isSlotFreedReply(frame));
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
if (handshakeSeen && !commandSent) {
|
|
1592
|
+
commandSent = true;
|
|
1593
|
+
sock.write(DELETE_SESSION_FRAME);
|
|
1594
|
+
}
|
|
1595
|
+
} catch {
|
|
1596
|
+
settle(false);
|
|
1597
|
+
}
|
|
1598
|
+
});
|
|
1599
|
+
try {
|
|
1600
|
+
sock.connect(marionettePort, HOST);
|
|
1601
|
+
} catch {
|
|
1602
|
+
settle(false);
|
|
1603
|
+
}
|
|
1604
|
+
});
|
|
1605
|
+
}
|
|
1606
|
+
var DEFAULT_MARIONETTE_PORT = 2828, DEFAULT_TIMEOUT_MS2 = 4000, HOST = "127.0.0.1", COLON = 58, DELETE_SESSION_FRAME, MarionetteProtocolError;
|
|
1607
|
+
var init_marionette = __esm(() => {
|
|
1608
|
+
DELETE_SESSION_FRAME = encodeFrame2([0, 1, "WebDriver:DeleteSession", {}]);
|
|
1609
|
+
MarionetteProtocolError = class MarionetteProtocolError extends Error {
|
|
1610
|
+
constructor(message) {
|
|
1611
|
+
super(message);
|
|
1612
|
+
this.name = "MarionetteProtocolError";
|
|
1613
|
+
}
|
|
1614
|
+
};
|
|
1615
|
+
});
|
|
1616
|
+
|
|
1617
|
+
// src/bidi/snapshot.ts
|
|
1618
|
+
async function takeStampedSnapshot(evaluate) {
|
|
1619
|
+
const raw = await evaluate(buildSnapshotFunctionSource());
|
|
1620
|
+
return coerceSnapshotResult(raw);
|
|
1621
|
+
}
|
|
1622
|
+
function coerceSnapshotResult(raw) {
|
|
1623
|
+
if (typeof raw !== "object" || raw === null || typeof raw.snapshot !== "string" || typeof raw.nodeCount !== "number") {
|
|
1624
|
+
throw new Error("takeStampedSnapshot: evaluate() returned an unexpected shape");
|
|
1625
|
+
}
|
|
1626
|
+
return { snapshot: raw.snapshot, nodeCount: raw.nodeCount };
|
|
1627
|
+
}
|
|
1628
|
+
function buildSnapshotFunctionSource() {
|
|
1629
|
+
return `function() {
|
|
1630
|
+
var STAMP_ATTR = ${JSON.stringify(STAMP_ATTR)};
|
|
1631
|
+
var MAX_NAME_LENGTH = ${MAX_NAME_LENGTH};
|
|
1632
|
+
var lines = [];
|
|
1633
|
+
var nodeCount = 0;
|
|
1634
|
+
|
|
1635
|
+
// THE UID CODEC (driver.ts) documents a bidi payload as exactly 12 lowercase hex chars,
|
|
1636
|
+
// /^[0-9a-f]{12}$/. Build it from two padded random blocks so the length is exact regardless
|
|
1637
|
+
// of the random value drawn, never from string concatenation that could come up short.
|
|
1638
|
+
function nextStamp() {
|
|
1639
|
+
var hi = Math.floor(Math.random() * 0x100000000).toString(16).padStart(8, "0");
|
|
1640
|
+
var lo = Math.floor(Math.random() * 0x10000).toString(16).padStart(4, "0");
|
|
1641
|
+
return hi + lo;
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
function stampOf(el) {
|
|
1645
|
+
var existing = el.getAttribute(STAMP_ATTR);
|
|
1646
|
+
if (existing) return existing;
|
|
1647
|
+
var stamp = nextStamp();
|
|
1648
|
+
el.setAttribute(STAMP_ATTR, stamp);
|
|
1649
|
+
return stamp;
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
// skipSizeCheck exempts <option>/<optgroup> from the zero-size prune: a
|
|
1653
|
+
// closed select's popup is unpainted (0x0) though its options are still
|
|
1654
|
+
// present and pickable. Other visibility rules still apply to them.
|
|
1655
|
+
function isVisible(el, skipSizeCheck) {
|
|
1656
|
+
if (el.hidden) return false;
|
|
1657
|
+
if (el.getAttribute("aria-hidden") === "true") return false;
|
|
1658
|
+
var style;
|
|
1659
|
+
try {
|
|
1660
|
+
style = el.ownerDocument.defaultView.getComputedStyle(el);
|
|
1661
|
+
} catch (e) {
|
|
1662
|
+
style = null;
|
|
1663
|
+
}
|
|
1664
|
+
if (style) {
|
|
1665
|
+
if (style.display === "none" || style.visibility === "hidden") return false;
|
|
1666
|
+
}
|
|
1667
|
+
if (!skipSizeCheck) {
|
|
1668
|
+
var rect = el.getBoundingClientRect();
|
|
1669
|
+
if (rect.width === 0 && rect.height === 0) return false;
|
|
1670
|
+
}
|
|
1671
|
+
return true;
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
function truncate(s) {
|
|
1675
|
+
s = s.trim().replace(/\\s+/g, " ");
|
|
1676
|
+
if (s.length > MAX_NAME_LENGTH) return s.slice(0, MAX_NAME_LENGTH - 1) + "\\u2026";
|
|
1677
|
+
return s;
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
function textOf(el) {
|
|
1681
|
+
return el.textContent || "";
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
function idRefsText(doc, ids) {
|
|
1685
|
+
var parts = [];
|
|
1686
|
+
var list = ids.trim().split(/\\s+/);
|
|
1687
|
+
for (var i = 0; i < list.length; i++) {
|
|
1688
|
+
var ref = doc.getElementById(list[i]);
|
|
1689
|
+
if (ref) parts.push(textOf(ref));
|
|
1690
|
+
}
|
|
1691
|
+
return parts.join(" ").trim();
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
// Text of a <label>, minus any nested form control's own content: a
|
|
1695
|
+
// label that WRAPS its control ("<label>Color <select>...</select></label>")
|
|
1696
|
+
// would otherwise include every option's text in the control's own name.
|
|
1697
|
+
function labelOwnText(label) {
|
|
1698
|
+
var clone = label.cloneNode(true);
|
|
1699
|
+
var controls = clone.querySelectorAll("input, select, textarea, button");
|
|
1700
|
+
for (var i = 0; i < controls.length; i++) {
|
|
1701
|
+
controls[i].parentNode.removeChild(controls[i]);
|
|
1702
|
+
}
|
|
1703
|
+
return textOf(clone);
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
// Guard: without this, an <option> inside a labelled <select> would
|
|
1707
|
+
// inherit the select's own label via closest("label"), not its text.
|
|
1708
|
+
var LABELABLE_TAGS = { INPUT: 1, TEXTAREA: 1, SELECT: 1, BUTTON: 1, METER: 1, OUTPUT: 1, PROGRESS: 1 };
|
|
1709
|
+
|
|
1710
|
+
function nativeLabelText(el) {
|
|
1711
|
+
if (!LABELABLE_TAGS[el.tagName]) return "";
|
|
1712
|
+
var doc = el.ownerDocument;
|
|
1713
|
+
if (el.id) {
|
|
1714
|
+
var forLabel = doc.querySelector('label[for="' + el.id + '"]');
|
|
1715
|
+
if (forLabel) return labelOwnText(forLabel);
|
|
1716
|
+
}
|
|
1717
|
+
var ancestor = el.closest ? el.closest("label") : null;
|
|
1718
|
+
if (ancestor) return labelOwnText(ancestor);
|
|
1719
|
+
return "";
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
// Reduced accname: aria-labelledby, then aria-label, then native label,
|
|
1723
|
+
// then alt/title/placeholder, then trimmed own text for content-named
|
|
1724
|
+
// elements. Not the full W3C accname algorithm (no recursive traversal
|
|
1725
|
+
// rules for nested presentational content) but covers the common cases.
|
|
1726
|
+
function accessibleName(el, contentNamed) {
|
|
1727
|
+
var doc = el.ownerDocument;
|
|
1728
|
+
var labelledby = el.getAttribute("aria-labelledby");
|
|
1729
|
+
if (labelledby) {
|
|
1730
|
+
var viaIds = idRefsText(doc, labelledby);
|
|
1731
|
+
if (viaIds) return truncate(viaIds);
|
|
1732
|
+
}
|
|
1733
|
+
var ariaLabel = el.getAttribute("aria-label");
|
|
1734
|
+
if (ariaLabel && ariaLabel.trim()) return truncate(ariaLabel);
|
|
1735
|
+
var native = nativeLabelText(el);
|
|
1736
|
+
if (native && native.trim()) return truncate(native);
|
|
1737
|
+
var alt = el.getAttribute("alt");
|
|
1738
|
+
if (alt && alt.trim()) return truncate(alt);
|
|
1739
|
+
var title = el.getAttribute("title");
|
|
1740
|
+
if (title && title.trim()) return truncate(title);
|
|
1741
|
+
var placeholder = el.getAttribute("placeholder");
|
|
1742
|
+
if (placeholder && placeholder.trim()) return truncate(placeholder);
|
|
1743
|
+
if (contentNamed) {
|
|
1744
|
+
var text = textOf(el);
|
|
1745
|
+
if (text && text.trim()) return truncate(text);
|
|
1746
|
+
}
|
|
1747
|
+
return "";
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
var IMPLICIT_ROLE_BY_TAG = {
|
|
1751
|
+
A: "link", BUTTON: "button", NAV: "navigation", MAIN: "main", FORM: "form", DIALOG: "dialog",
|
|
1752
|
+
UL: "list", OL: "list", LI: "listitem", TABLE: "table", TR: "row", TD: "cell", TH: "columnheader",
|
|
1753
|
+
SELECT: "combobox", OPTION: "option", TEXTAREA: "textbox", IMG: "img",
|
|
1754
|
+
H1: "heading", H2: "heading", H3: "heading", H4: "heading", H5: "heading", H6: "heading",
|
|
1755
|
+
PROGRESS: "progressbar", METER: "meter"
|
|
1756
|
+
};
|
|
1757
|
+
|
|
1758
|
+
var INPUT_TYPE_ROLE = {
|
|
1759
|
+
button: "button", submit: "button", reset: "button", checkbox: "checkbox", radio: "radio",
|
|
1760
|
+
range: "slider", number: "spinbutton", search: "searchbox", email: "textbox", tel: "textbox",
|
|
1761
|
+
url: "textbox", password: "textbox", text: "textbox", image: "button"
|
|
1762
|
+
};
|
|
1763
|
+
|
|
1764
|
+
var CONTENT_NAMED_ROLES = {
|
|
1765
|
+
button: true, link: true, heading: true, option: true, cell: true,
|
|
1766
|
+
columnheader: true, listitem: true, menuitem: true, tab: true
|
|
1767
|
+
};
|
|
1768
|
+
|
|
1769
|
+
function implicitRole(el) {
|
|
1770
|
+
var explicit = el.getAttribute("role");
|
|
1771
|
+
if (explicit) return explicit;
|
|
1772
|
+
var tag = el.tagName;
|
|
1773
|
+
if (tag === "INPUT") {
|
|
1774
|
+
var type = (el.getAttribute("type") || "text").toLowerCase();
|
|
1775
|
+
return INPUT_TYPE_ROLE[type] || "textbox";
|
|
1776
|
+
}
|
|
1777
|
+
if (tag === "A" && !el.getAttribute("href")) return "";
|
|
1778
|
+
if (Object.prototype.hasOwnProperty.call(IMPLICIT_ROLE_BY_TAG, tag)) {
|
|
1779
|
+
return IMPLICIT_ROLE_BY_TAG[tag];
|
|
1780
|
+
}
|
|
1781
|
+
// Deliberate divergence: Chrome's real AX tree says "generic" here,
|
|
1782
|
+
// but "textbox" is the useful signal an agent needs. Chrome is out of
|
|
1783
|
+
// step, do not "fix" this toward parity.
|
|
1784
|
+
if (el.isContentEditable) return "textbox";
|
|
1785
|
+
return "";
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
function headingLevel(el) {
|
|
1789
|
+
var m = /^H([1-6])$/.exec(el.tagName);
|
|
1790
|
+
return m ? m[1] : null;
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
function currentValue(el) {
|
|
1794
|
+
var tag = el.tagName;
|
|
1795
|
+
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") {
|
|
1796
|
+
return el.value;
|
|
1797
|
+
}
|
|
1798
|
+
if (el.isContentEditable) return textOf(el);
|
|
1799
|
+
return undefined;
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
function isChecked(el) {
|
|
1803
|
+
var tag = el.tagName;
|
|
1804
|
+
if (tag === "INPUT") {
|
|
1805
|
+
var type = (el.getAttribute("type") || "").toLowerCase();
|
|
1806
|
+
if (type === "checkbox" || type === "radio") return !!el.checked;
|
|
1807
|
+
}
|
|
1808
|
+
var aria = el.getAttribute("aria-checked");
|
|
1809
|
+
if (aria === "true") return true;
|
|
1810
|
+
return false;
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
function describeNode(el, role) {
|
|
1814
|
+
var contentNamed = !!CONTENT_NAMED_ROLES[role];
|
|
1815
|
+
var name = accessibleName(el, contentNamed);
|
|
1816
|
+
var extras = [];
|
|
1817
|
+
var value = currentValue(el);
|
|
1818
|
+
if (value !== undefined && value !== "") extras.push("value=" + JSON.stringify(String(value)));
|
|
1819
|
+
if (isChecked(el)) extras.push("checked=true");
|
|
1820
|
+
if (el.tagName === "OPTION" && el.selected) extras.push("selected");
|
|
1821
|
+
var href = el.getAttribute("href");
|
|
1822
|
+
if (href) extras.push("url=" + href);
|
|
1823
|
+
if (el.disabled || el.getAttribute("aria-disabled") === "true") extras.push("disabled");
|
|
1824
|
+
var level = headingLevel(el);
|
|
1825
|
+
if (level) extras.push("level=" + level);
|
|
1826
|
+
var label = name ? " " + JSON.stringify(name) : "";
|
|
1827
|
+
var extra = extras.length ? " [" + extras.join(" ") + "]" : "";
|
|
1828
|
+
return { role: role, label: label, extra: extra, name: name };
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
// Traverses light DOM, open shadow roots, and same-origin iframe documents.
|
|
1832
|
+
// A cross-origin iframe throws on contentDocument access; caught and
|
|
1833
|
+
// skipped rather than aborting the whole walk, and noted in a comment
|
|
1834
|
+
// here rather than silently vanishing from the tree without a trace.
|
|
1835
|
+
function walk(root, depth) {
|
|
1836
|
+
var children = root.children ? Array.prototype.slice.call(root.children) : [];
|
|
1837
|
+
for (var i = 0; i < children.length; i++) {
|
|
1838
|
+
var el = children[i];
|
|
1839
|
+
var isSelectChild = el.tagName === "OPTION" || el.tagName === "OPTGROUP";
|
|
1840
|
+
if (!isVisible(el, isSelectChild)) continue;
|
|
1841
|
+
|
|
1842
|
+
var role = implicitRole(el);
|
|
1843
|
+
var isLandmarkOrControl = !!role;
|
|
1844
|
+
var described = null;
|
|
1845
|
+
if (isLandmarkOrControl) {
|
|
1846
|
+
described = describeNode(el, role);
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
var hasContent = described && (described.name || described.extra);
|
|
1850
|
+
var isStructural = isLandmarkOrControl && (hasContent || isInteractiveRole(role) || isLandmarkRole(role));
|
|
1851
|
+
|
|
1852
|
+
// Role-less visible leaf text (measured vs Chrome's take_snapshot,
|
|
1853
|
+
// which keeps a StaticText under a generic wrapper for this case,
|
|
1854
|
+
// e.g. a plain status <div>). Only a leaf (no element children, so
|
|
1855
|
+
// its text is not already captured by a nested control/label).
|
|
1856
|
+
var hasElementChildren = !!(el.children && el.children.length > 0);
|
|
1857
|
+
var leafName = !isStructural && !hasElementChildren ? truncate(textOf(el)) : "";
|
|
1858
|
+
var isLeafText = !!leafName;
|
|
1859
|
+
|
|
1860
|
+
var nextDepth = depth;
|
|
1861
|
+
if (isStructural || isLeafText) {
|
|
1862
|
+
var stamp = stampOf(el);
|
|
1863
|
+
var indent = new Array(depth + 1).join(" ");
|
|
1864
|
+
var lineTail = isStructural
|
|
1865
|
+
? described.role + described.label + described.extra
|
|
1866
|
+
: "text " + JSON.stringify(leafName);
|
|
1867
|
+
lines.push(indent + "[" + stamp + "] " + lineTail);
|
|
1868
|
+
nodeCount += 1;
|
|
1869
|
+
nextDepth = depth + 1;
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
if (el.shadowRoot) {
|
|
1873
|
+
walk(el.shadowRoot, nextDepth);
|
|
1874
|
+
} else if (el.tagName === "IFRAME") {
|
|
1875
|
+
try {
|
|
1876
|
+
var doc = el.contentDocument;
|
|
1877
|
+
if (doc && doc.body) walk(doc.body, nextDepth);
|
|
1878
|
+
} catch (e) {
|
|
1879
|
+
// cross-origin iframe: intentionally skipped, not silently merged
|
|
1880
|
+
// into the parent tree as though it were empty.
|
|
1881
|
+
}
|
|
1882
|
+
} else {
|
|
1883
|
+
walk(el, nextDepth);
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
function isInteractiveRole(role) {
|
|
1889
|
+
return (
|
|
1890
|
+
role === "button" ||
|
|
1891
|
+
role === "link" ||
|
|
1892
|
+
role === "textbox" ||
|
|
1893
|
+
role === "searchbox" ||
|
|
1894
|
+
role === "checkbox" ||
|
|
1895
|
+
role === "radio" ||
|
|
1896
|
+
role === "combobox" ||
|
|
1897
|
+
role === "listbox" ||
|
|
1898
|
+
role === "option" ||
|
|
1899
|
+
role === "slider" ||
|
|
1900
|
+
role === "spinbutton" ||
|
|
1901
|
+
role === "progressbar" ||
|
|
1902
|
+
role === "meter"
|
|
1903
|
+
);
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
function isLandmarkRole(role) {
|
|
1907
|
+
return (
|
|
1908
|
+
role === "navigation" ||
|
|
1909
|
+
role === "main" ||
|
|
1910
|
+
role === "form" ||
|
|
1911
|
+
role === "dialog" ||
|
|
1912
|
+
role === "region" ||
|
|
1913
|
+
role === "heading" ||
|
|
1914
|
+
role === "list" ||
|
|
1915
|
+
role === "listitem" ||
|
|
1916
|
+
role === "table" ||
|
|
1917
|
+
role === "row" ||
|
|
1918
|
+
role === "cell" ||
|
|
1919
|
+
role === "columnheader" ||
|
|
1920
|
+
role === "img"
|
|
1921
|
+
);
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
walk(document.body, 0);
|
|
1925
|
+
return { snapshot: lines.join("\\n"), nodeCount: nodeCount };
|
|
1926
|
+
}`;
|
|
1927
|
+
}
|
|
1928
|
+
var STAMP_ATTR = "data-cdp-uid", MAX_NAME_LENGTH = 120;
|
|
1929
|
+
|
|
1930
|
+
// src/client.ts
|
|
1931
|
+
class CdpConnection {
|
|
1932
|
+
wsUrl;
|
|
1933
|
+
opts;
|
|
1934
|
+
ws;
|
|
1935
|
+
nextId = 0;
|
|
1936
|
+
pending = new Map;
|
|
1937
|
+
listeners = new Map;
|
|
1938
|
+
closed = false;
|
|
1939
|
+
constructor(wsUrl, opts = {}) {
|
|
1940
|
+
this.wsUrl = wsUrl;
|
|
1941
|
+
this.opts = opts;
|
|
1942
|
+
}
|
|
1943
|
+
connect() {
|
|
1944
|
+
return new Promise((resolve, reject) => {
|
|
1945
|
+
const ws = new WebSocket(this.wsUrl);
|
|
1946
|
+
this.ws = ws;
|
|
1947
|
+
const openTimer = setTimeout(() => reject(new CdpError(`connect timeout: ${this.wsUrl}`)), this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS3);
|
|
1948
|
+
ws.onopen = () => {
|
|
1949
|
+
clearTimeout(openTimer);
|
|
1950
|
+
resolve(this);
|
|
1951
|
+
};
|
|
1952
|
+
ws.onerror = (e) => {
|
|
1953
|
+
clearTimeout(openTimer);
|
|
1954
|
+
const msg = e?.message ?? "websocket error";
|
|
1955
|
+
if (this.pending.size === 0)
|
|
1956
|
+
reject(new CdpError(msg));
|
|
1957
|
+
this.rejectAll(new CdpError(msg));
|
|
1958
|
+
};
|
|
1959
|
+
ws.onclose = () => {
|
|
1960
|
+
this.closed = true;
|
|
1961
|
+
this.rejectAll(new CdpError("connection closed"));
|
|
1962
|
+
};
|
|
1963
|
+
ws.onmessage = (ev) => this.onMessage(String(ev.data));
|
|
1964
|
+
});
|
|
1965
|
+
}
|
|
1966
|
+
onMessage(raw) {
|
|
1967
|
+
let msg;
|
|
1968
|
+
try {
|
|
1969
|
+
msg = JSON.parse(raw);
|
|
1970
|
+
} catch {
|
|
1971
|
+
return;
|
|
1972
|
+
}
|
|
1973
|
+
if (typeof msg.id === "number" && this.pending.has(msg.id)) {
|
|
1974
|
+
const p = this.pending.get(msg.id);
|
|
1975
|
+
clearTimeout(p.timer);
|
|
1976
|
+
this.pending.delete(msg.id);
|
|
1977
|
+
p.resolve(msg);
|
|
1978
|
+
return;
|
|
1979
|
+
}
|
|
1980
|
+
if (msg.method) {
|
|
1981
|
+
const set = this.listeners.get(msg.method);
|
|
1982
|
+
if (set)
|
|
1983
|
+
for (const h of set)
|
|
1984
|
+
h(msg.params ?? {}, msg.sessionId);
|
|
1985
|
+
const star = this.listeners.get("*");
|
|
1986
|
+
if (star)
|
|
1987
|
+
for (const h of star)
|
|
1988
|
+
h({ method: msg.method, ...msg.params ?? {} }, msg.sessionId);
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
rejectAll(err) {
|
|
1992
|
+
for (const [, p] of this.pending) {
|
|
1993
|
+
clearTimeout(p.timer);
|
|
1994
|
+
p.reject(err);
|
|
1995
|
+
}
|
|
1996
|
+
this.pending.clear();
|
|
1997
|
+
}
|
|
1998
|
+
send(method, params = {}, opts = {}) {
|
|
1999
|
+
if (this.closed || !this.ws)
|
|
2000
|
+
return Promise.reject(new CdpError("connection not open"));
|
|
2001
|
+
const id = ++this.nextId;
|
|
2002
|
+
const timeoutMs = opts.timeoutMs ?? this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS3;
|
|
2003
|
+
const payload = { id, method, params };
|
|
2004
|
+
if (opts.sessionId)
|
|
2005
|
+
payload.sessionId = opts.sessionId;
|
|
2006
|
+
return new Promise((resolve, reject) => {
|
|
2007
|
+
const timer = setTimeout(() => {
|
|
2008
|
+
this.pending.delete(id);
|
|
2009
|
+
reject(new CdpError(`'${method}' timed out after ${timeoutMs}ms`));
|
|
2010
|
+
}, timeoutMs);
|
|
2011
|
+
this.pending.set(id, {
|
|
2012
|
+
resolve: (r) => {
|
|
2013
|
+
if (r.error)
|
|
2014
|
+
reject(new CdpError(`${method}: ${r.error.message}`, r.error.code, r.error.data));
|
|
2015
|
+
else
|
|
2016
|
+
resolve(r.result ?? {});
|
|
2017
|
+
},
|
|
2018
|
+
reject,
|
|
2019
|
+
timer
|
|
2020
|
+
});
|
|
2021
|
+
this.ws.send(JSON.stringify(payload));
|
|
2022
|
+
});
|
|
2023
|
+
}
|
|
2024
|
+
on(method, handler) {
|
|
2025
|
+
let set = this.listeners.get(method);
|
|
2026
|
+
if (!set) {
|
|
2027
|
+
set = new Set;
|
|
2028
|
+
this.listeners.set(method, set);
|
|
2029
|
+
}
|
|
2030
|
+
set.add(handler);
|
|
2031
|
+
return () => set.delete(handler);
|
|
2032
|
+
}
|
|
2033
|
+
waitFor(method, predicate, timeoutMs = this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS3) {
|
|
2034
|
+
return new Promise((resolve, reject) => {
|
|
2035
|
+
const timer = setTimeout(() => {
|
|
2036
|
+
off();
|
|
2037
|
+
reject(new CdpError(`waitFor('${method}') timed out after ${timeoutMs}ms`));
|
|
2038
|
+
}, timeoutMs);
|
|
2039
|
+
const off = this.on(method, (params) => {
|
|
2040
|
+
if (!predicate || predicate(params)) {
|
|
2041
|
+
clearTimeout(timer);
|
|
2042
|
+
off();
|
|
2043
|
+
resolve(params);
|
|
2044
|
+
}
|
|
2045
|
+
});
|
|
2046
|
+
});
|
|
2047
|
+
}
|
|
2048
|
+
close() {
|
|
2049
|
+
this.closed = true;
|
|
2050
|
+
this.rejectAll(new CdpError("closed by caller"));
|
|
2051
|
+
try {
|
|
2052
|
+
this.ws?.close();
|
|
2053
|
+
} catch {}
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
async function httpJson(path) {
|
|
2057
|
+
const res = await fetch(`${BASE}${path}`);
|
|
2058
|
+
if (!res.ok)
|
|
2059
|
+
throw new CdpError(`${path} -> HTTP ${res.status}`);
|
|
2060
|
+
return await res.json();
|
|
2061
|
+
}
|
|
2062
|
+
function listTargets() {
|
|
2063
|
+
return httpJson("/json/list");
|
|
2064
|
+
}
|
|
2065
|
+
async function browserWsUrl() {
|
|
2066
|
+
const v = await httpJson("/json/version");
|
|
2067
|
+
return v.webSocketDebuggerUrl;
|
|
2068
|
+
}
|
|
2069
|
+
async function resolveTarget(selector, opts = {}) {
|
|
2070
|
+
const targets = await listTargets();
|
|
2071
|
+
const hit = await pickTarget(targets, selector);
|
|
2072
|
+
if (isWorkerTargetType(hit.type))
|
|
2073
|
+
return hit;
|
|
2074
|
+
await assertLeaseOk("chrome", hit.id, { lease: opts.lease, url: hit.url, title: hit.title });
|
|
2075
|
+
return hit;
|
|
2076
|
+
}
|
|
2077
|
+
async function pickTarget(targets, selector) {
|
|
2078
|
+
const pages = targets.filter((t) => t.type === "page");
|
|
2079
|
+
if (!selector || selector === "active") {
|
|
2080
|
+
if (!pages.length)
|
|
2081
|
+
throw new CdpError("no page targets open");
|
|
2082
|
+
return pages[0];
|
|
2083
|
+
}
|
|
2084
|
+
if (selector.startsWith("index:")) {
|
|
2085
|
+
const i = Number(selector.slice(6));
|
|
2086
|
+
if (!pages[i])
|
|
2087
|
+
throw new CdpError(`no page target at index ${i} (have ${pages.length})`);
|
|
2088
|
+
return pages[i];
|
|
2089
|
+
}
|
|
2090
|
+
if (selector.startsWith("url:")) {
|
|
2091
|
+
const needle = selector.slice(4);
|
|
2092
|
+
const hit = pages.find((t) => t.url.includes(needle));
|
|
2093
|
+
if (!hit)
|
|
2094
|
+
throw new CdpError(`no page url containing '${needle}'`);
|
|
2095
|
+
return hit;
|
|
2096
|
+
}
|
|
2097
|
+
if (selector.startsWith("title:")) {
|
|
2098
|
+
const needle = selector.slice(6);
|
|
2099
|
+
const hit = pages.find((t) => t.title.includes(needle));
|
|
2100
|
+
if (!hit)
|
|
2101
|
+
throw new CdpError(`no page title containing '${needle}'`);
|
|
2102
|
+
return hit;
|
|
2103
|
+
}
|
|
2104
|
+
if (selector.startsWith("label:")) {
|
|
2105
|
+
const label = selector.slice(6);
|
|
2106
|
+
const { matchIds, knownLabels } = await resolveLiveLabel("chrome", label, pages.map((t) => t.id));
|
|
2107
|
+
if (matchIds.length > 1) {
|
|
2108
|
+
throw new CdpError(`label 'label:${label}' matches more than one live target: ${matchIds.join(", ")}. Resolve by target id instead.`);
|
|
2109
|
+
}
|
|
2110
|
+
if (matchIds.length === 1) {
|
|
2111
|
+
const hit = pages.find((t) => t.id === matchIds[0]);
|
|
2112
|
+
if (hit)
|
|
2113
|
+
return hit;
|
|
2114
|
+
}
|
|
2115
|
+
throw new CdpError(knownLabels.length ? `no live target with label '${label}' (labels currently in use: ${knownLabels.join(", ")})` : `no live target with label '${label}' (no labels are currently assigned to any open target)`);
|
|
2116
|
+
}
|
|
2117
|
+
if (isWorkerSelector(selector)) {
|
|
2118
|
+
const needle = workerNeedle(selector);
|
|
2119
|
+
if (needle === "")
|
|
2120
|
+
throw new CdpError(WORKER_EMPTY_NEEDLE_MESSAGE);
|
|
2121
|
+
const { matches, liveWorkers } = resolveWorkerTargets(targets, needle);
|
|
2122
|
+
if (matches.length > 1)
|
|
2123
|
+
throw new CdpError(workerAmbiguityMessage(needle, matches));
|
|
2124
|
+
if (matches.length === 1)
|
|
2125
|
+
return matches[0];
|
|
2126
|
+
throw new CdpError(workerMissMessage(needle, { wakeAttempted: false, liveWorkers }));
|
|
2127
|
+
}
|
|
2128
|
+
const byId = targets.find((t) => t.id === selector);
|
|
2129
|
+
if (!byId)
|
|
2130
|
+
throw new CdpError(`no target with id '${selector}'`);
|
|
2131
|
+
return byId;
|
|
2132
|
+
}
|
|
2133
|
+
async function openBrowser(opts = {}) {
|
|
2134
|
+
return new CdpConnection(await browserWsUrl(), opts).connect();
|
|
2135
|
+
}
|
|
2136
|
+
async function openPage(selector, opts = {}) {
|
|
2137
|
+
const target = await resolveTarget(selector, { lease: opts.lease });
|
|
2138
|
+
const conn = await new CdpConnection(target.webSocketDebuggerUrl, opts).connect();
|
|
2139
|
+
return { conn, target };
|
|
2140
|
+
}
|
|
2141
|
+
async function withPage(selector, fn, opts = {}) {
|
|
2142
|
+
const { conn, target } = await openPage(selector, opts);
|
|
2143
|
+
try {
|
|
2144
|
+
return await fn(conn, target);
|
|
2145
|
+
} finally {
|
|
2146
|
+
conn.close();
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
var BASE, DEFAULT_TIMEOUT_MS3, CdpError;
|
|
2150
|
+
var init_client2 = __esm(() => {
|
|
2151
|
+
init_leases();
|
|
2152
|
+
init_origins();
|
|
2153
|
+
init_workers();
|
|
2154
|
+
BASE = process.env.CDP_BASE ?? "http://127.0.0.1:9222";
|
|
2155
|
+
DEFAULT_TIMEOUT_MS3 = Number(process.env.CDP_TIMEOUT_MS ?? 15000);
|
|
2156
|
+
CdpError = class CdpError extends Error {
|
|
2157
|
+
code;
|
|
2158
|
+
data;
|
|
2159
|
+
constructor(message, code, data) {
|
|
2160
|
+
super(message);
|
|
2161
|
+
this.code = code;
|
|
2162
|
+
this.data = data;
|
|
2163
|
+
this.name = "CdpError";
|
|
2164
|
+
}
|
|
2165
|
+
};
|
|
2166
|
+
});
|
|
2167
|
+
|
|
2168
|
+
// src/tools/network_mock.ts
|
|
2169
|
+
function escapeRegExpChar(ch) {
|
|
2170
|
+
return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
|
|
2171
|
+
}
|
|
2172
|
+
function globToRegExp(pattern) {
|
|
2173
|
+
let re = "";
|
|
2174
|
+
for (let i = 0;i < pattern.length; i++) {
|
|
2175
|
+
const ch = pattern[i];
|
|
2176
|
+
if (ch === "\\") {
|
|
2177
|
+
const next = pattern[i + 1];
|
|
2178
|
+
if (next !== undefined) {
|
|
2179
|
+
re += escapeRegExpChar(next);
|
|
2180
|
+
i++;
|
|
2181
|
+
} else {
|
|
2182
|
+
re += "\\\\";
|
|
2183
|
+
}
|
|
2184
|
+
} else if (ch === "*") {
|
|
2185
|
+
re += ".*";
|
|
2186
|
+
} else if (ch === "?") {
|
|
2187
|
+
re += ".";
|
|
2188
|
+
} else {
|
|
2189
|
+
re += escapeRegExpChar(ch);
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
return new RegExp(`^${re}$`);
|
|
2193
|
+
}
|
|
2194
|
+
function urlMatches(pattern, url) {
|
|
2195
|
+
return globToRegExp(pattern).test(url);
|
|
2196
|
+
}
|
|
2197
|
+
function selectRule(rules, url, method) {
|
|
2198
|
+
return rules.find((r) => urlMatches(r.urlPattern, url) && (!r.method || r.method.toUpperCase() === method.toUpperCase()));
|
|
2199
|
+
}
|
|
2200
|
+
function buildFulfillParams(requestId, rule) {
|
|
2201
|
+
const merged = new Map;
|
|
2202
|
+
merged.set("content-type", { name: "Content-Type", value: rule.contentType ?? "application/json" });
|
|
2203
|
+
for (const [name, value] of Object.entries(rule.headers ?? {})) {
|
|
2204
|
+
merged.set(name.toLowerCase(), { name, value });
|
|
2205
|
+
}
|
|
2206
|
+
return {
|
|
2207
|
+
requestId,
|
|
2208
|
+
responseCode: rule.status ?? 200,
|
|
2209
|
+
responseHeaders: [...merged.values()],
|
|
2210
|
+
body: Buffer.from(rule.body ?? "", "utf8").toString("base64")
|
|
2211
|
+
};
|
|
2212
|
+
}
|
|
2213
|
+
function effectiveAction(rule, rnd) {
|
|
2214
|
+
if (rule.failRate !== undefined && rule.failRate > 0 && rnd < rule.failRate)
|
|
2215
|
+
return "fail";
|
|
2216
|
+
return rule.action;
|
|
2217
|
+
}
|
|
2218
|
+
function delay(ms) {
|
|
2219
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
2220
|
+
}
|
|
2221
|
+
function registerHandler(session) {
|
|
2222
|
+
session.conn.on("Fetch.requestPaused", (params) => {
|
|
2223
|
+
const p = params;
|
|
2224
|
+
session.chain = session.chain.then(async () => {
|
|
2225
|
+
const rule = selectRule(session.rules, p.request.url, p.request.method);
|
|
2226
|
+
if (!rule) {
|
|
2227
|
+
await session.conn.send("Fetch.continueRequest", { requestId: p.requestId });
|
|
2228
|
+
return;
|
|
2229
|
+
}
|
|
2230
|
+
if (rule.delayMs)
|
|
2231
|
+
await delay(rule.delayMs);
|
|
2232
|
+
const act = effectiveAction(rule, Math.random());
|
|
2233
|
+
if (act === "fulfill") {
|
|
2234
|
+
await session.conn.send("Fetch.fulfillRequest", { ...buildFulfillParams(p.requestId, rule) });
|
|
2235
|
+
} else if (act === "fail") {
|
|
2236
|
+
await session.conn.send("Fetch.failRequest", { requestId: p.requestId, errorReason: rule.errorReason ?? "Failed" });
|
|
2237
|
+
} else {
|
|
2238
|
+
await session.conn.send("Fetch.continueRequest", { requestId: p.requestId });
|
|
2239
|
+
}
|
|
2240
|
+
rule.hits++;
|
|
2241
|
+
session.intercepted.push({ url: p.request.url, method: p.request.method, action: act });
|
|
2242
|
+
}).catch((e) => {
|
|
2243
|
+
if (/closed|connection|Target|Inspected/i.test(String(e)))
|
|
2244
|
+
session.dead = true;
|
|
2245
|
+
});
|
|
2246
|
+
});
|
|
2247
|
+
}
|
|
2248
|
+
async function mockRequest(args) {
|
|
2249
|
+
if (!args.urlPattern)
|
|
2250
|
+
throw new CdpError("mock_request requires a { urlPattern }");
|
|
2251
|
+
const rule = {
|
|
2252
|
+
urlPattern: args.urlPattern,
|
|
2253
|
+
action: args.action ?? "fulfill",
|
|
2254
|
+
status: args.status,
|
|
2255
|
+
body: args.body,
|
|
2256
|
+
contentType: args.contentType,
|
|
2257
|
+
headers: args.headers,
|
|
2258
|
+
errorReason: args.errorReason,
|
|
2259
|
+
method: args.method,
|
|
2260
|
+
delayMs: args.delayMs,
|
|
2261
|
+
failRate: args.failRate,
|
|
2262
|
+
hits: 0
|
|
2263
|
+
};
|
|
2264
|
+
const resolved = await resolveTarget(args.target);
|
|
2265
|
+
let session = sessions.get(resolved.id);
|
|
2266
|
+
if (session && !session.dead) {
|
|
2267
|
+
const idx = session.rules.findIndex((r) => r.urlPattern === rule.urlPattern && (r.method ?? "") === (rule.method ?? ""));
|
|
2268
|
+
if (idx >= 0)
|
|
2269
|
+
session.rules[idx] = rule;
|
|
2270
|
+
else
|
|
2271
|
+
session.rules.push(rule);
|
|
2272
|
+
await session.conn.send("Fetch.enable", { patterns: session.rules.map((r) => ({ urlPattern: r.urlPattern })) });
|
|
2273
|
+
} else {
|
|
2274
|
+
const { conn, target } = await openPage(resolved.id);
|
|
2275
|
+
session = { conn, target, rules: [rule], intercepted: [], chain: Promise.resolve(), dead: false };
|
|
2276
|
+
registerHandler(session);
|
|
2277
|
+
await conn.send("Fetch.enable", { patterns: [{ urlPattern: rule.urlPattern }] });
|
|
2278
|
+
sessions.set(target.id, session);
|
|
2279
|
+
}
|
|
2280
|
+
let reloaded = false;
|
|
2281
|
+
if (args.reload) {
|
|
2282
|
+
await session.conn.send("Page.enable");
|
|
2283
|
+
await session.conn.send("Page.reload", { ignoreCache: true });
|
|
2284
|
+
await delay(800);
|
|
2285
|
+
reloaded = true;
|
|
2286
|
+
}
|
|
2287
|
+
return {
|
|
2288
|
+
target: { id: session.target.id, url: session.target.url, title: session.target.title },
|
|
2289
|
+
pattern: rule.urlPattern,
|
|
2290
|
+
ruleCount: session.rules.length,
|
|
2291
|
+
reloaded
|
|
2292
|
+
};
|
|
2293
|
+
}
|
|
2294
|
+
async function listMocks(_args = {}) {
|
|
2295
|
+
for (const [id, s] of sessions) {
|
|
2296
|
+
if (s.dead) {
|
|
2297
|
+
try {
|
|
2298
|
+
s.conn.close();
|
|
2299
|
+
} catch {}
|
|
2300
|
+
sessions.delete(id);
|
|
2301
|
+
continue;
|
|
2302
|
+
}
|
|
2303
|
+
try {
|
|
2304
|
+
await s.conn.send("Runtime.evaluate", { expression: "1", returnByValue: true }, { timeoutMs: 3000 });
|
|
2305
|
+
} catch {
|
|
2306
|
+
try {
|
|
2307
|
+
s.conn.close();
|
|
2308
|
+
} catch {}
|
|
2309
|
+
sessions.delete(id);
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
const mocks = [...sessions.values()].map((s) => ({
|
|
2313
|
+
target: { id: s.target.id, url: s.target.url, title: s.target.title },
|
|
2314
|
+
rules: s.rules.map((r) => ({ urlPattern: r.urlPattern, action: r.action, method: r.method, hits: r.hits })),
|
|
2315
|
+
hits: s.intercepted.length
|
|
2316
|
+
}));
|
|
2317
|
+
return { count: mocks.length, mocks };
|
|
2318
|
+
}
|
|
2319
|
+
async function clearMocks(args = {}) {
|
|
2320
|
+
let ids;
|
|
2321
|
+
if (args.all) {
|
|
2322
|
+
ids = [...sessions.keys()];
|
|
2323
|
+
} else {
|
|
2324
|
+
const resolved = await resolveTarget(args.target);
|
|
2325
|
+
ids = sessions.has(resolved.id) ? [resolved.id] : [];
|
|
2326
|
+
}
|
|
2327
|
+
let cleared = 0;
|
|
2328
|
+
for (const id of ids) {
|
|
2329
|
+
const s = sessions.get(id);
|
|
2330
|
+
if (!s)
|
|
2331
|
+
continue;
|
|
2332
|
+
try {
|
|
2333
|
+
await s.conn.send("Fetch.disable");
|
|
2334
|
+
} catch {}
|
|
2335
|
+
try {
|
|
2336
|
+
s.conn.close();
|
|
2337
|
+
} catch {}
|
|
2338
|
+
sessions.delete(id);
|
|
2339
|
+
cleared++;
|
|
2340
|
+
}
|
|
2341
|
+
return { cleared };
|
|
2342
|
+
}
|
|
2343
|
+
var sessions;
|
|
2344
|
+
var init_network_mock = __esm(() => {
|
|
2345
|
+
init_client2();
|
|
2346
|
+
sessions = new Map;
|
|
2347
|
+
});
|
|
2348
|
+
|
|
2349
|
+
// src/bidi/driver.ts
|
|
2350
|
+
import { spawn } from "node:child_process";
|
|
2351
|
+
import { fileURLToPath } from "node:url";
|
|
2352
|
+
import { existsSync } from "node:fs";
|
|
2353
|
+
function normalizeBidiCookie(c) {
|
|
2354
|
+
const value = c.value.type === "base64" ? Buffer.from(c.value.value, "base64").toString("utf8") : c.value.value;
|
|
2355
|
+
const hasExpiry = typeof c.expiry === "number";
|
|
2356
|
+
return {
|
|
2357
|
+
name: c.name,
|
|
2358
|
+
value,
|
|
2359
|
+
domain: c.domain,
|
|
2360
|
+
path: c.path,
|
|
2361
|
+
expires: hasExpiry ? c.expiry : -1,
|
|
2362
|
+
size: typeof c.size === "number" ? c.size : Buffer.byteLength(`${c.name}${value}`, "utf8"),
|
|
2363
|
+
httpOnly: c.httpOnly === true,
|
|
2364
|
+
secure: c.secure === true,
|
|
2365
|
+
sameSite: c.sameSite ?? "default",
|
|
2366
|
+
session: !hasExpiry
|
|
2367
|
+
};
|
|
2368
|
+
}
|
|
2369
|
+
function hostFromUrl(url, toolName) {
|
|
2370
|
+
if (url === undefined || url === "") {
|
|
2371
|
+
throw driverError("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.`);
|
|
2372
|
+
}
|
|
2373
|
+
let host = "";
|
|
2374
|
+
try {
|
|
2375
|
+
host = new URL(url).hostname;
|
|
2376
|
+
} catch {
|
|
2377
|
+
throw driverError("unsupported", `${toolName} could not parse the url '${url}' to derive the 'domain' BiDi requires. Pass 'domain' explicitly.`);
|
|
2378
|
+
}
|
|
2379
|
+
if (host === "") {
|
|
2380
|
+
throw driverError("unsupported", `${toolName} cannot derive a 'domain' from '${url}': that url has no host. Pass 'domain' explicitly.`);
|
|
2381
|
+
}
|
|
2382
|
+
return host;
|
|
2383
|
+
}
|
|
2384
|
+
function driverError(code, message, data) {
|
|
2385
|
+
return Object.assign(new Error(message), { code, ...data !== undefined ? { data } : {} });
|
|
2386
|
+
}
|
|
2387
|
+
function encodeUid(stamp) {
|
|
2388
|
+
return `bidi:${stamp}`;
|
|
2389
|
+
}
|
|
2390
|
+
function decodeStamp(uid) {
|
|
2391
|
+
if (!uid.startsWith("bidi:"))
|
|
2392
|
+
throw driverError("foreign-uid", `uid is not owned by the bidi driver: ${uid}`);
|
|
2393
|
+
const payload = uid.slice(5);
|
|
2394
|
+
if (!payload.length)
|
|
2395
|
+
throw driverError("foreign-uid", `malformed bidi uid payload: ${uid}`);
|
|
2396
|
+
return payload;
|
|
2397
|
+
}
|
|
2398
|
+
function cssEscape(s) {
|
|
2399
|
+
return s.replace(/["\\]/g, (c) => `\\${c}`);
|
|
2400
|
+
}
|
|
2401
|
+
function mapBidiError(e) {
|
|
2402
|
+
if (e instanceof BidiError) {
|
|
2403
|
+
const code = e.code;
|
|
2404
|
+
if (code === "no such node" || code === "no such element")
|
|
2405
|
+
return driverError("no-such-element", e.message);
|
|
2406
|
+
if (code === "no such frame")
|
|
2407
|
+
return driverError("no-such-target", e.message);
|
|
2408
|
+
if (code === "invalid session id")
|
|
2409
|
+
return driverError("disconnected", e.message);
|
|
2410
|
+
if (code === "unsupported operation")
|
|
2411
|
+
return driverError("unsupported", e.message);
|
|
2412
|
+
if (!code && /timed out/i.test(e.message))
|
|
2413
|
+
return driverError("timeout", e.message);
|
|
2414
|
+
if (!code && /(disposed|not open|connection closed)/i.test(e.message))
|
|
2415
|
+
return driverError("disconnected", e.message);
|
|
2416
|
+
if (/NS_ERROR_FILE_ACCESS_DENIED/i.test(e.message)) {
|
|
2417
|
+
return driverError("page-error", `${e.message} — the browser process was not allowed to open this file:// path. For a snap- or AppArmor-confined Firefox (Ubuntu default) that is kernel-level and no preference fixes it: serve the directory over a local http server (e.g. python3 -m http.server) and navigate to the http://127.0.0.1:<port>/ URL instead. If this Firefox was launched by cdp-toolkit and still fails, please report it at https://github.com/sblattj/cdp-toolkit/issues.`);
|
|
2418
|
+
}
|
|
2419
|
+
return driverError("page-error", e.message);
|
|
2420
|
+
}
|
|
2421
|
+
return driverError("page-error", e instanceof Error ? e.message : String(e));
|
|
2422
|
+
}
|
|
2423
|
+
function serializeArg(v) {
|
|
2424
|
+
if (v === undefined)
|
|
2425
|
+
return { type: "undefined" };
|
|
2426
|
+
if (v === null)
|
|
2427
|
+
return { type: "null" };
|
|
2428
|
+
if (typeof v === "string")
|
|
2429
|
+
return { type: "string", value: v };
|
|
2430
|
+
if (typeof v === "number")
|
|
2431
|
+
return { type: "number", value: Number.isNaN(v) ? "NaN" : v };
|
|
2432
|
+
if (typeof v === "boolean")
|
|
2433
|
+
return { type: "boolean", value: v };
|
|
2434
|
+
if (Array.isArray(v))
|
|
2435
|
+
return { type: "array", value: v.map(serializeArg) };
|
|
2436
|
+
if (typeof v === "object")
|
|
2437
|
+
return { type: "object", value: Object.entries(v).map(([k, val]) => [k, serializeArg(val)]) };
|
|
2438
|
+
return { type: "string", value: String(v) };
|
|
2439
|
+
}
|
|
2440
|
+
function deserializeRemote(rv) {
|
|
2441
|
+
switch (rv.type) {
|
|
2442
|
+
case "undefined":
|
|
2443
|
+
return;
|
|
2444
|
+
case "null":
|
|
2445
|
+
return null;
|
|
2446
|
+
case "string":
|
|
2447
|
+
return rv.value;
|
|
2448
|
+
case "boolean":
|
|
2449
|
+
return rv.value;
|
|
2450
|
+
case "bigint":
|
|
2451
|
+
return rv.value;
|
|
2452
|
+
case "number":
|
|
2453
|
+
return typeof rv.value === "number" ? rv.value : rv.value;
|
|
2454
|
+
case "array":
|
|
2455
|
+
return (rv.value ?? []).map(deserializeRemote);
|
|
2456
|
+
case "object": {
|
|
2457
|
+
const out = {};
|
|
2458
|
+
for (const [k, v] of rv.value ?? [])
|
|
2459
|
+
out[typeof k === "string" ? k : String(deserializeRemote(k))] = deserializeRemote(v);
|
|
2460
|
+
return out;
|
|
2461
|
+
}
|
|
2462
|
+
case "node":
|
|
2463
|
+
return { bidiSharedId: rv.sharedId };
|
|
2464
|
+
default:
|
|
2465
|
+
return;
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
function exceptionMessage(d) {
|
|
2469
|
+
if (d.text)
|
|
2470
|
+
return d.text;
|
|
2471
|
+
const ex = deserializeRemote(d.exception);
|
|
2472
|
+
return typeof ex === "string" ? ex : "evaluation threw";
|
|
2473
|
+
}
|
|
2474
|
+
function splitExtras(s) {
|
|
2475
|
+
const out = [];
|
|
2476
|
+
let cur = "";
|
|
2477
|
+
let inQuotes = false;
|
|
2478
|
+
for (let i = 0;i < s.length; i++) {
|
|
2479
|
+
const ch = s[i];
|
|
2480
|
+
if (ch === '"' && s[i - 1] !== "\\")
|
|
2481
|
+
inQuotes = !inQuotes;
|
|
2482
|
+
if (ch === " " && !inQuotes) {
|
|
2483
|
+
if (cur)
|
|
2484
|
+
out.push(cur);
|
|
2485
|
+
cur = "";
|
|
2486
|
+
} else
|
|
2487
|
+
cur += ch;
|
|
2488
|
+
}
|
|
2489
|
+
if (cur)
|
|
2490
|
+
out.push(cur);
|
|
2491
|
+
return out;
|
|
2492
|
+
}
|
|
2493
|
+
function parseSnapshotLine(line) {
|
|
2494
|
+
const m = LINE_RE.exec(line);
|
|
2495
|
+
if (!m)
|
|
2496
|
+
return;
|
|
2497
|
+
const indent = m[1] ?? "", stamp = m[2], role = m[3], nameLit = m[4], extrasRaw = m[5];
|
|
2498
|
+
const depth = indent.length / 2;
|
|
2499
|
+
let name;
|
|
2500
|
+
if (nameLit) {
|
|
2501
|
+
try {
|
|
2502
|
+
name = JSON.parse(nameLit);
|
|
2503
|
+
} catch {
|
|
2504
|
+
name = nameLit;
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2507
|
+
const extras = {};
|
|
2508
|
+
if (extrasRaw) {
|
|
2509
|
+
for (const tok of splitExtras(extrasRaw)) {
|
|
2510
|
+
const eq = tok.indexOf("=");
|
|
2511
|
+
if (eq === -1) {
|
|
2512
|
+
extras[tok] = "true";
|
|
2513
|
+
continue;
|
|
2514
|
+
}
|
|
2515
|
+
const key = tok.slice(0, eq);
|
|
2516
|
+
let val = tok.slice(eq + 1);
|
|
2517
|
+
if (val.startsWith('"')) {
|
|
2518
|
+
try {
|
|
2519
|
+
val = JSON.parse(val);
|
|
2520
|
+
} catch {}
|
|
2521
|
+
}
|
|
2522
|
+
extras[key] = val;
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
return { uid: encodeUid(stamp), role, depth, ...name !== undefined ? { name } : {}, ...Object.keys(extras).length ? { extras } : {} };
|
|
2526
|
+
}
|
|
2527
|
+
async function locateNodes(conn, contextId, locator) {
|
|
2528
|
+
try {
|
|
2529
|
+
const { nodes } = await conn.send("browsingContext.locateNodes", { context: contextId, locator, maxNodeCount: 1 });
|
|
2530
|
+
return nodes;
|
|
2531
|
+
} catch (e) {
|
|
2532
|
+
throw mapBidiError(e);
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
function sharedRefOf(node, describe) {
|
|
2536
|
+
if (!node.sharedId)
|
|
2537
|
+
throw driverError("page-error", `located node has no sharedId (${describe})`);
|
|
2538
|
+
return { sharedId: node.sharedId };
|
|
2539
|
+
}
|
|
2540
|
+
async function resolveElementLocator(conn, contextId, loc) {
|
|
2541
|
+
if ("uid" in loc) {
|
|
2542
|
+
const stamp = decodeStamp(loc.uid);
|
|
2543
|
+
const nodes = await locateNodes(conn, contextId, { type: "css", value: `[${UID_STAMP_ATTR}="${cssEscape(stamp)}"]` });
|
|
2544
|
+
if (!nodes.length)
|
|
2545
|
+
throw driverError("stale-uid", `uid does not resolve: ${loc.uid}`);
|
|
2546
|
+
return sharedRefOf(nodes[0], loc.uid);
|
|
2547
|
+
}
|
|
2548
|
+
if ("css" in loc) {
|
|
2549
|
+
const nodes = await locateNodes(conn, contextId, { type: "css", value: loc.css });
|
|
2550
|
+
if (!nodes.length)
|
|
2551
|
+
throw driverError("no-such-element", `css '${loc.css}' matched no element`);
|
|
2552
|
+
return sharedRefOf(nodes[0], `css '${loc.css}'`);
|
|
2553
|
+
}
|
|
2554
|
+
if ("xpath" in loc) {
|
|
2555
|
+
const nodes = await locateNodes(conn, contextId, { type: "xpath", value: loc.xpath });
|
|
2556
|
+
if (!nodes.length)
|
|
2557
|
+
throw driverError("no-such-element", `xpath '${loc.xpath}' matched no element`);
|
|
2558
|
+
return sharedRefOf(nodes[0], `xpath '${loc.xpath}'`);
|
|
2559
|
+
}
|
|
2560
|
+
const nodes = await locateNodes(conn, contextId, { type: "innerText", value: loc.text, matchType: "partial" });
|
|
2561
|
+
if (!nodes.length)
|
|
2562
|
+
throw driverError("no-such-element", `text '${loc.text}' matched no element`);
|
|
2563
|
+
return sharedRefOf(nodes[0], `text '${loc.text}'`);
|
|
2564
|
+
}
|
|
2565
|
+
function bumpEpoch(endpoint, conn) {
|
|
2566
|
+
sessionEpochs.set(endpoint, (sessionEpochs.get(endpoint) ?? 0) + 1);
|
|
2567
|
+
connEndpoints.set(conn, endpoint);
|
|
2568
|
+
}
|
|
2569
|
+
function withStaleIdHint(conn, err) {
|
|
2570
|
+
const endpoint = connEndpoints.get(conn);
|
|
2571
|
+
if (endpoint === undefined)
|
|
2572
|
+
return err;
|
|
2573
|
+
if ((sessionEpochs.get(endpoint) ?? 0) <= 1)
|
|
2574
|
+
return err;
|
|
2575
|
+
const e = err;
|
|
2576
|
+
if (!isDriverError(e) || e.code !== "no-such-target")
|
|
2577
|
+
return err;
|
|
2578
|
+
if (e.message.includes("re-established"))
|
|
2579
|
+
return err;
|
|
2580
|
+
e.message += STALE_ID_HINT;
|
|
2581
|
+
return e;
|
|
2582
|
+
}
|
|
2583
|
+
function muxMode(attached) {
|
|
2584
|
+
const raw = process.env.CDP_FIREFOX_MUX;
|
|
2585
|
+
if (raw === "off")
|
|
2586
|
+
return "off";
|
|
2587
|
+
if (raw === "host")
|
|
2588
|
+
return "host";
|
|
2589
|
+
if (raw === "daemon")
|
|
2590
|
+
return "daemon";
|
|
2591
|
+
return attached ? "daemon" : "host";
|
|
2592
|
+
}
|
|
2593
|
+
function resolveDaemonScript() {
|
|
2594
|
+
const override = process.env.CDP_FIREFOX_MUX_DAEMON;
|
|
2595
|
+
if (override)
|
|
2596
|
+
return override;
|
|
2597
|
+
for (const rel of ["./mux-daemon.ts", "./mux-daemon.js", "./bidi/mux-daemon.js", "../bidi/mux-daemon.js"]) {
|
|
2598
|
+
let p;
|
|
2599
|
+
try {
|
|
2600
|
+
p = fileURLToPath(new URL(rel, import.meta.url));
|
|
2601
|
+
} catch {
|
|
2602
|
+
continue;
|
|
2603
|
+
}
|
|
2604
|
+
if (existsSync(p))
|
|
2605
|
+
return p;
|
|
2606
|
+
}
|
|
2607
|
+
return;
|
|
2608
|
+
}
|
|
2609
|
+
async function liveAdvertisedHolder(endpoint) {
|
|
2610
|
+
const rec = await readSessionSlot(endpoint).catch(() => {
|
|
2611
|
+
return;
|
|
2612
|
+
});
|
|
2613
|
+
if (!rec || !rec.muxEndpoint || !isPidAlive(rec.pid))
|
|
2614
|
+
return;
|
|
2615
|
+
return { endpoint: rec.muxEndpoint, holder: rec };
|
|
2616
|
+
}
|
|
2617
|
+
async function ensureMuxDaemon(endpoint) {
|
|
2618
|
+
const already = await liveAdvertisedHolder(endpoint);
|
|
2619
|
+
if (already)
|
|
2620
|
+
return already;
|
|
2621
|
+
const script = resolveDaemonScript();
|
|
2622
|
+
if (!script)
|
|
2623
|
+
return;
|
|
2624
|
+
let exited = false;
|
|
2625
|
+
const child = spawn(process.execPath, [script, endpoint, String(process.pid)], {
|
|
2626
|
+
detached: true,
|
|
2627
|
+
stdio: "ignore",
|
|
2628
|
+
env: { ...process.env, CDP_FIREFOX_MUX: "host" }
|
|
2629
|
+
});
|
|
2630
|
+
child.once("error", () => {
|
|
2631
|
+
exited = true;
|
|
2632
|
+
});
|
|
2633
|
+
child.once("exit", () => {
|
|
2634
|
+
exited = true;
|
|
2635
|
+
});
|
|
2636
|
+
child.unref();
|
|
2637
|
+
const deadline = Date.now() + sessionWaitMs() + 5000;
|
|
2638
|
+
while (Date.now() < deadline) {
|
|
2639
|
+
const hit = await liveAdvertisedHolder(endpoint);
|
|
2640
|
+
if (hit)
|
|
2641
|
+
return hit;
|
|
2642
|
+
if (exited)
|
|
2643
|
+
return;
|
|
2644
|
+
await sleep2(50);
|
|
2645
|
+
}
|
|
2646
|
+
throw driverError("disconnected", `Firefox's single WebDriver BiDi session on ${endpoint} could not be brought up: a mux daemon was ` + `spawned (${script}) but never advertised a multiplexer within ${sessionWaitMs() + 5000}ms. ` + `Its diagnostics are in ${muxDaemonLogFile(endpoint)}. Retry, or set CDP_FIREFOX_MUX=host to run ` + `the session in this process instead.`);
|
|
2647
|
+
}
|
|
2648
|
+
async function releaseSlotOnDialFailure(endpoint, owned) {
|
|
2649
|
+
if (owned)
|
|
2650
|
+
return;
|
|
2651
|
+
const h = slotHandles.get(endpoint);
|
|
2652
|
+
if (h) {
|
|
2653
|
+
await releaseSessionSlot(h).catch(() => {
|
|
2654
|
+
return;
|
|
2655
|
+
});
|
|
2656
|
+
slotHandles.delete(endpoint);
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
function orphanNotClearedError(endpoint) {
|
|
2660
|
+
return driverError("disconnected", `Firefox holds an orphaned WebDriver BiDi session on ${endpoint} that could not be auto-cleared. ` + `Marionette recovery needs Firefox launched with --marionette (default port 2828, or set ` + `CDP_FIREFOX_MARIONETTE_PORT); otherwise restart Firefox to free the slot. (A client killed without ` + `session.end can leave a session Firefox does not reap.)`);
|
|
2661
|
+
}
|
|
2662
|
+
async function getConnection(endpoint, timeoutMs, attached = false) {
|
|
2663
|
+
const existing = connections.get(endpoint);
|
|
2664
|
+
if (existing && existing.isOpen) {
|
|
2665
|
+
existing.retain();
|
|
2666
|
+
return existing;
|
|
2667
|
+
}
|
|
2668
|
+
if (existing)
|
|
2669
|
+
connections.delete(endpoint);
|
|
2670
|
+
const inflight = connecting.get(endpoint);
|
|
2671
|
+
if (inflight) {
|
|
2672
|
+
const conn = await inflight;
|
|
2673
|
+
conn.retain();
|
|
2674
|
+
return conn;
|
|
2675
|
+
}
|
|
2676
|
+
const create = (async () => {
|
|
2677
|
+
const mport = defaultMarionettePort();
|
|
2678
|
+
const dialOpts = {
|
|
2679
|
+
...timeoutMs !== undefined ? { timeoutMs } : {},
|
|
2680
|
+
capabilities: { alwaysMatch: { unhandledPromptBehavior: { default: "ignore" } } }
|
|
2681
|
+
};
|
|
2682
|
+
let owned = slotHandles.has(endpoint);
|
|
2683
|
+
if (joined.has(endpoint)) {
|
|
2684
|
+
joined.delete(endpoint);
|
|
2685
|
+
owned = false;
|
|
2686
|
+
}
|
|
2687
|
+
if (!owned && muxMode(attached) === "daemon") {
|
|
2688
|
+
const target = await ensureMuxDaemon(endpoint);
|
|
2689
|
+
if (target) {
|
|
2690
|
+
try {
|
|
2691
|
+
const joinedConn = await connectBidiSessionUrl(target.endpoint, dialOpts);
|
|
2692
|
+
connections.set(endpoint, joinedConn);
|
|
2693
|
+
joined.add(endpoint);
|
|
2694
|
+
bumpEpoch(endpoint, joinedConn);
|
|
2695
|
+
return joinedConn;
|
|
2696
|
+
} catch (e) {
|
|
2697
|
+
console.error(`[cdp-toolkit] firefox mux daemon at ${target.endpoint} could not be dialed: ${e instanceof Error ? e.message : String(e)}`);
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
}
|
|
2701
|
+
for (let attempt = 0;; attempt++) {
|
|
2702
|
+
if (owned)
|
|
2703
|
+
break;
|
|
2704
|
+
let acq;
|
|
2705
|
+
try {
|
|
2706
|
+
acq = await acquireSessionSlot(endpoint, { label: slotLabelOverride ?? defaultLabel(), marionettePort: mport });
|
|
2707
|
+
} catch (e) {
|
|
2708
|
+
if (e instanceof SessionSlotBusyError) {
|
|
2709
|
+
const who = e.holderLabel ? `'${e.holderLabel}' (pid ${e.holderPid})` : "another live process";
|
|
2710
|
+
throw driverError("disconnected", `Firefox's single WebDriver BiDi session on ${endpoint} is held by a LIVE process ${who}. ` + `Firefox serves exactly ONE BiDi session per browser instance, so this server cannot take it ` + `while that process is running. Wait for it to release the session and retry, or point this ` + `server at a different endpoint/browser.`);
|
|
2711
|
+
}
|
|
2712
|
+
throw e;
|
|
2713
|
+
}
|
|
2714
|
+
if (acq.joinMux) {
|
|
2715
|
+
const { endpoint: muxEndpoint, holder } = acq.joinMux;
|
|
2716
|
+
try {
|
|
2717
|
+
const joinedConn = await connectBidiSessionUrl(muxEndpoint, dialOpts);
|
|
2718
|
+
connections.set(endpoint, joinedConn);
|
|
2719
|
+
joined.add(endpoint);
|
|
2720
|
+
bumpEpoch(endpoint, joinedConn);
|
|
2721
|
+
return joinedConn;
|
|
2722
|
+
} catch (e) {
|
|
2723
|
+
if (attempt === 0)
|
|
2724
|
+
continue;
|
|
2725
|
+
const who = `'${holder.label}' (pid ${holder.pid})`;
|
|
2726
|
+
throw driverError("disconnected", `Firefox's single WebDriver BiDi session on ${endpoint} is held by a LIVE process ${who}, ` + `which advertises a BiDi multiplexer at ${muxEndpoint}, but that multiplexer could not be ` + `dialed (${e instanceof Error ? e.message : String(e)}). The holder is most likely shutting ` + `down; retry in a moment, or point this server at a different endpoint/browser.`);
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2729
|
+
if (acq.handle)
|
|
2730
|
+
slotHandles.set(endpoint, acq.handle);
|
|
2731
|
+
if (acq.staleHolder) {
|
|
2732
|
+
await forceClearOrphanSession(acq.staleHolder.marionettePort ?? mport);
|
|
2733
|
+
}
|
|
2734
|
+
break;
|
|
2735
|
+
}
|
|
2736
|
+
let conn;
|
|
2737
|
+
try {
|
|
2738
|
+
conn = await connectBidiSessionUrl(endpoint, dialOpts);
|
|
2739
|
+
} catch (e) {
|
|
2740
|
+
if (e instanceof BidiError && /maximum number of active sessions/i.test(e.message)) {
|
|
2741
|
+
const cleared = await forceClearOrphanSession(mport);
|
|
2742
|
+
if (!cleared) {
|
|
2743
|
+
await releaseSlotOnDialFailure(endpoint, owned);
|
|
2744
|
+
throw orphanNotClearedError(endpoint);
|
|
2745
|
+
}
|
|
2746
|
+
try {
|
|
2747
|
+
conn = await connectBidiSessionUrl(endpoint, dialOpts);
|
|
2748
|
+
} catch (e2) {
|
|
2749
|
+
await releaseSlotOnDialFailure(endpoint, owned);
|
|
2750
|
+
if (e2 instanceof BidiError && /maximum number of active sessions/i.test(e2.message)) {
|
|
2751
|
+
throw orphanNotClearedError(endpoint);
|
|
2752
|
+
}
|
|
2753
|
+
throw e2;
|
|
2754
|
+
}
|
|
2755
|
+
} else {
|
|
2756
|
+
await releaseSlotOnDialFailure(endpoint, owned);
|
|
2757
|
+
throw e;
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2760
|
+
connections.set(endpoint, conn);
|
|
2761
|
+
bumpEpoch(endpoint, conn);
|
|
2762
|
+
if (muxMode(attached) !== "off") {
|
|
2763
|
+
try {
|
|
2764
|
+
const mux = await startBidiMux(conn);
|
|
2765
|
+
muxes.set(endpoint, mux);
|
|
2766
|
+
const h = slotHandles.get(endpoint);
|
|
2767
|
+
if (h)
|
|
2768
|
+
await advertiseMux(h, mux.endpoint);
|
|
2769
|
+
} catch (e) {
|
|
2770
|
+
console.error(`[cdp-toolkit] firefox mux not started: ${e instanceof Error ? e.message : String(e)}`);
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
2773
|
+
return conn;
|
|
2774
|
+
})();
|
|
2775
|
+
connecting.set(endpoint, create);
|
|
2776
|
+
try {
|
|
2777
|
+
return await create;
|
|
2778
|
+
} finally {
|
|
2779
|
+
connecting.delete(endpoint);
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
async function safeTitle(conn, contextId) {
|
|
2783
|
+
try {
|
|
2784
|
+
const res = await conn.send("script.evaluate", { expression: "document.title", target: { context: contextId }, awaitPromise: false });
|
|
2785
|
+
return res.type === "success" && res.result.type === "string" ? res.result.value : "";
|
|
2786
|
+
} catch {
|
|
2787
|
+
return "";
|
|
2788
|
+
}
|
|
2789
|
+
}
|
|
2790
|
+
async function pageInfoOf(conn, ctx) {
|
|
2791
|
+
return { id: ctx.context, url: ctx.url, title: await safeTitle(conn, ctx.context) };
|
|
2792
|
+
}
|
|
2793
|
+
async function resolveContext(conn, selector, opts = {}) {
|
|
2794
|
+
const { contexts } = await conn.send("browsingContext.getTree", {}).catch((e) => {
|
|
2795
|
+
throw mapBidiError(e);
|
|
2796
|
+
});
|
|
2797
|
+
if (!contexts.length)
|
|
2798
|
+
throw driverError("no-such-target", "no browsing contexts available");
|
|
2799
|
+
const hit = await pickContext(conn, contexts, selector).catch((e) => {
|
|
2800
|
+
throw withStaleIdHint(conn, e);
|
|
2801
|
+
});
|
|
2802
|
+
await assertLeaseOk("firefox", hit.context, { lease: opts.lease, url: hit.url });
|
|
2803
|
+
return hit;
|
|
2804
|
+
}
|
|
2805
|
+
async function pickContext(conn, contexts, selector) {
|
|
2806
|
+
if (selector === undefined || selector === "active")
|
|
2807
|
+
return contexts[0];
|
|
2808
|
+
if (selector.startsWith("index:")) {
|
|
2809
|
+
const t = contexts[Number(selector.slice(6))];
|
|
2810
|
+
if (!t)
|
|
2811
|
+
throw driverError("no-such-target", `no context at ${selector}`);
|
|
2812
|
+
return t;
|
|
2813
|
+
}
|
|
2814
|
+
if (selector.startsWith("url:")) {
|
|
2815
|
+
const needle = selector.slice(4);
|
|
2816
|
+
const t = contexts.find((c) => c.url.includes(needle));
|
|
2817
|
+
if (!t)
|
|
2818
|
+
throw driverError("no-such-target", `no context with url containing '${needle}'`);
|
|
2819
|
+
return t;
|
|
2820
|
+
}
|
|
2821
|
+
if (selector.startsWith("title:")) {
|
|
2822
|
+
const needle = selector.slice(6);
|
|
2823
|
+
for (const c of contexts)
|
|
2824
|
+
if ((await safeTitle(conn, c.context)).includes(needle))
|
|
2825
|
+
return c;
|
|
2826
|
+
throw driverError("no-such-target", `no context with title containing '${needle}'`);
|
|
2827
|
+
}
|
|
2828
|
+
if (selector.startsWith("label:")) {
|
|
2829
|
+
const label = selector.slice(6);
|
|
2830
|
+
const { matchIds, knownLabels } = await resolveLiveLabel("firefox", label, contexts.map((c) => c.context));
|
|
2831
|
+
if (matchIds.length > 1) {
|
|
2832
|
+
throw driverError("no-such-target", `label 'label:${label}' matches more than one live target: ${matchIds.join(", ")}. Resolve by target id instead.`);
|
|
2833
|
+
}
|
|
2834
|
+
if (matchIds.length === 1) {
|
|
2835
|
+
const hit = contexts.find((c) => c.context === matchIds[0]);
|
|
2836
|
+
if (hit)
|
|
2837
|
+
return hit;
|
|
2838
|
+
}
|
|
2839
|
+
throw driverError("no-such-target", knownLabels.length ? `no live target with label '${label}' (labels currently in use: ${knownLabels.join(", ")})` : `no live target with label '${label}' (no labels are currently assigned to any open target)`);
|
|
2840
|
+
}
|
|
2841
|
+
if (isWorkerSelector(selector)) {
|
|
2842
|
+
throw driverError("unsupported", WORKER_SELECTOR_UNSUPPORTED_MESSAGE);
|
|
2843
|
+
}
|
|
2844
|
+
const exact = contexts.find((c) => c.context === selector);
|
|
2845
|
+
if (exact)
|
|
2846
|
+
return exact;
|
|
2847
|
+
throw driverError("no-such-target", `no context matching '${selector}'`);
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
class BidiBrowserDriver {
|
|
2851
|
+
endpoint;
|
|
2852
|
+
timeoutMs;
|
|
2853
|
+
attached;
|
|
2854
|
+
scheme = "bidi";
|
|
2855
|
+
lifetime = "session";
|
|
2856
|
+
capabilities = BIDI_CAPABILITIES;
|
|
2857
|
+
uidStability = "document-stamp";
|
|
2858
|
+
snapshotFidelity = "dom-heuristic";
|
|
2859
|
+
constructor(endpoint, timeoutMs, attached = false) {
|
|
2860
|
+
this.endpoint = endpoint;
|
|
2861
|
+
this.timeoutMs = timeoutMs;
|
|
2862
|
+
this.attached = attached;
|
|
2863
|
+
}
|
|
2864
|
+
async listPages() {
|
|
2865
|
+
const conn = await getConnection(this.endpoint, this.timeoutMs, this.attached);
|
|
2866
|
+
try {
|
|
2867
|
+
const { contexts } = await conn.send("browsingContext.getTree", {}).catch((e) => {
|
|
2868
|
+
throw mapBidiError(e);
|
|
2869
|
+
});
|
|
2870
|
+
return Promise.all(contexts.map((c) => pageInfoOf(conn, c)));
|
|
2871
|
+
} finally {
|
|
2872
|
+
conn.release();
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
async newPage(url) {
|
|
2876
|
+
const conn = await getConnection(this.endpoint, this.timeoutMs, this.attached);
|
|
2877
|
+
try {
|
|
2878
|
+
const { context } = await conn.send("browsingContext.create", { type: "tab" }).catch((e) => {
|
|
2879
|
+
throw mapBidiError(e);
|
|
2880
|
+
});
|
|
2881
|
+
if (url)
|
|
2882
|
+
await conn.send("browsingContext.navigate", { context, url, wait: "complete" }).catch((e) => {
|
|
2883
|
+
throw mapBidiError(e);
|
|
2884
|
+
});
|
|
2885
|
+
return { id: context, url: url ?? "about:blank", title: await safeTitle(conn, context) };
|
|
2886
|
+
} finally {
|
|
2887
|
+
conn.release();
|
|
2888
|
+
}
|
|
2889
|
+
}
|
|
2890
|
+
async closePage(id) {
|
|
2891
|
+
const conn = await getConnection(this.endpoint, this.timeoutMs, this.attached);
|
|
2892
|
+
try {
|
|
2893
|
+
await conn.send("browsingContext.close", { context: id }).catch((e) => {
|
|
2894
|
+
throw mapBidiError(e);
|
|
2895
|
+
});
|
|
2896
|
+
return { success: true };
|
|
2897
|
+
} finally {
|
|
2898
|
+
conn.release();
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
async activatePage(id) {
|
|
2902
|
+
const conn = await getConnection(this.endpoint, this.timeoutMs, this.attached);
|
|
2903
|
+
try {
|
|
2904
|
+
await conn.send("browsingContext.activate", { context: id }).catch((e) => {
|
|
2905
|
+
throw mapBidiError(e);
|
|
2906
|
+
});
|
|
2907
|
+
const ctx = await resolveContext(conn, id);
|
|
2908
|
+
return await pageInfoOf(conn, ctx);
|
|
2909
|
+
} finally {
|
|
2910
|
+
conn.release();
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2913
|
+
async page(selector) {
|
|
2914
|
+
const conn = await getConnection(this.endpoint, this.timeoutMs, this.attached);
|
|
2915
|
+
let ctx;
|
|
2916
|
+
try {
|
|
2917
|
+
ctx = await resolveContext(conn, selector);
|
|
2918
|
+
} catch (e) {
|
|
2919
|
+
conn.release();
|
|
2920
|
+
throw e;
|
|
2921
|
+
}
|
|
2922
|
+
const info = await pageInfoOf(conn, ctx);
|
|
2923
|
+
return new BidiPageDriver(conn, ctx.context, info, this);
|
|
2924
|
+
}
|
|
2925
|
+
async installActivityBeacon(contextId) {
|
|
2926
|
+
const conn = await getConnection(this.endpoint, this.timeoutMs, this.attached).catch(() => {
|
|
2927
|
+
return;
|
|
2928
|
+
});
|
|
2929
|
+
if (!conn)
|
|
2930
|
+
return false;
|
|
2931
|
+
try {
|
|
2932
|
+
const key = `${this.endpoint}:${contextId}`;
|
|
2933
|
+
if (!bidiBeaconScripts.has(key)) {
|
|
2934
|
+
const registered = await conn.send("script.addPreloadScript", {
|
|
2935
|
+
functionDeclaration: BEACON_FUNCTION_DECLARATION,
|
|
2936
|
+
contexts: [contextId]
|
|
2937
|
+
});
|
|
2938
|
+
bidiBeaconScripts.set(key, registered.script);
|
|
2939
|
+
}
|
|
2940
|
+
await conn.send("script.evaluate", {
|
|
2941
|
+
expression: BEACON_SOURCE,
|
|
2942
|
+
target: { context: contextId },
|
|
2943
|
+
awaitPromise: false
|
|
2944
|
+
});
|
|
2945
|
+
return true;
|
|
2946
|
+
} catch {
|
|
2947
|
+
return false;
|
|
2948
|
+
} finally {
|
|
2949
|
+
conn.release();
|
|
2950
|
+
}
|
|
2951
|
+
}
|
|
2952
|
+
async readActivityBeacon(contextId) {
|
|
2953
|
+
const conn = await getConnection(this.endpoint, this.timeoutMs, this.attached).catch(() => {
|
|
2954
|
+
return;
|
|
2955
|
+
});
|
|
2956
|
+
if (!conn)
|
|
2957
|
+
return null;
|
|
2958
|
+
try {
|
|
2959
|
+
const res = await conn.send("script.evaluate", {
|
|
2960
|
+
expression: BEACON_READ_EXPRESSION,
|
|
2961
|
+
target: { context: contextId },
|
|
2962
|
+
awaitPromise: false
|
|
2963
|
+
});
|
|
2964
|
+
if (res.type !== "success")
|
|
2965
|
+
return null;
|
|
2966
|
+
const value = deserializeRemote(res.result);
|
|
2967
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
2968
|
+
} catch {
|
|
2969
|
+
return null;
|
|
2970
|
+
} finally {
|
|
2971
|
+
conn.release();
|
|
2972
|
+
}
|
|
2973
|
+
}
|
|
2974
|
+
async dispose() {
|
|
2975
|
+
for (const key of [...bidiBeaconScripts.keys()]) {
|
|
2976
|
+
if (key.startsWith(`${this.endpoint}:`))
|
|
2977
|
+
bidiBeaconScripts.delete(key);
|
|
2978
|
+
}
|
|
2979
|
+
connecting.delete(this.endpoint);
|
|
2980
|
+
const mux = muxes.get(this.endpoint);
|
|
2981
|
+
if (mux) {
|
|
2982
|
+
muxes.delete(this.endpoint);
|
|
2983
|
+
await mux.close().catch(() => {
|
|
2984
|
+
return;
|
|
2985
|
+
});
|
|
2986
|
+
}
|
|
2987
|
+
const conn = connections.get(this.endpoint);
|
|
2988
|
+
if (!conn) {
|
|
2989
|
+
joined.delete(this.endpoint);
|
|
2990
|
+
await this.releaseSessionSlotIfHeld();
|
|
2991
|
+
return;
|
|
2992
|
+
}
|
|
2993
|
+
connections.delete(this.endpoint);
|
|
2994
|
+
if (joined.has(this.endpoint)) {
|
|
2995
|
+
joined.delete(this.endpoint);
|
|
2996
|
+
conn.dispose();
|
|
2997
|
+
return;
|
|
2998
|
+
}
|
|
2999
|
+
await conn.send("session.end", {}).catch(() => {
|
|
3000
|
+
return;
|
|
3001
|
+
});
|
|
3002
|
+
conn.dispose();
|
|
3003
|
+
await this.releaseSessionSlotIfHeld();
|
|
3004
|
+
}
|
|
3005
|
+
async releaseSessionSlotIfHeld() {
|
|
3006
|
+
const h = slotHandles.get(this.endpoint);
|
|
3007
|
+
if (!h)
|
|
3008
|
+
return;
|
|
3009
|
+
await releaseSessionSlot(h).catch(() => {
|
|
3010
|
+
return;
|
|
3011
|
+
});
|
|
3012
|
+
slotHandles.delete(this.endpoint);
|
|
3013
|
+
}
|
|
3014
|
+
}
|
|
3015
|
+
function createFirefoxDriver(port, opts) {
|
|
3016
|
+
return new BidiBrowserDriver(`ws://127.0.0.1:${port}/session`, opts?.timeoutMs);
|
|
3017
|
+
}
|
|
3018
|
+
async function hostFirefoxMux(endpoint, opts = {}) {
|
|
3019
|
+
if (opts.label)
|
|
3020
|
+
slotLabelOverride = opts.label;
|
|
3021
|
+
if (process.env.CDP_FIREFOX_MUX !== "host") {
|
|
3022
|
+
throw new Error("hostFirefoxMux requires CDP_FIREFOX_MUX=host (set it before importing this module)");
|
|
3023
|
+
}
|
|
3024
|
+
const conn = await getConnection(endpoint, undefined, true);
|
|
3025
|
+
if (joined.has(endpoint)) {
|
|
3026
|
+
joined.delete(endpoint);
|
|
3027
|
+
connections.delete(endpoint);
|
|
3028
|
+
conn.dispose();
|
|
3029
|
+
throw new Error(`endpoint ${endpoint} is already served by another holder's multiplexer`);
|
|
3030
|
+
}
|
|
3031
|
+
const mux = muxes.get(endpoint);
|
|
3032
|
+
if (!mux) {
|
|
3033
|
+
const driver = new BidiBrowserDriver(endpoint, undefined, true);
|
|
3034
|
+
await driver.dispose().catch(() => {
|
|
3035
|
+
return;
|
|
3036
|
+
});
|
|
3037
|
+
throw new Error(`could not start a BiDi multiplexer for ${endpoint}`);
|
|
3038
|
+
}
|
|
3039
|
+
const driver = new BidiBrowserDriver(endpoint, undefined, true);
|
|
3040
|
+
return {
|
|
3041
|
+
mux,
|
|
3042
|
+
conn,
|
|
3043
|
+
dispose: () => driver.dispose()
|
|
3044
|
+
};
|
|
3045
|
+
}
|
|
3046
|
+
function createFirefoxDriverForEndpoint(wsUrl, opts) {
|
|
3047
|
+
return new BidiBrowserDriver(wsUrl, opts?.timeoutMs, true);
|
|
3048
|
+
}
|
|
3049
|
+
var LINE_RE, INTERACTIVE_ROLES, STAMP_ONE_SOURCE, KEY_VALUES, MODIFIER_VALUES, BUTTON_CODES, ARM_SCROLL_SETTLE_WATCH_SOURCE, AWAIT_SCROLL_SETTLE_SOURCE = "function(){return window.__cdpScrollSettle;}", BIDI_CAPABILITIES, connections, slotHandles, muxes, joined, connecting, sessionEpochs, connEndpoints, STALE_ID_HINT, sleep2 = (ms) => new Promise((r) => setTimeout(r, ms)), slotLabelOverride, bidiBeaconScripts, BidiPageDriver;
|
|
3050
|
+
var init_driver2 = __esm(() => {
|
|
3051
|
+
init_driver();
|
|
3052
|
+
init_leases();
|
|
3053
|
+
init_origins();
|
|
3054
|
+
init_workers();
|
|
3055
|
+
init_activity();
|
|
3056
|
+
init_client();
|
|
3057
|
+
init_session_lease();
|
|
3058
|
+
init_mux();
|
|
3059
|
+
init_marionette();
|
|
3060
|
+
init_network_mock();
|
|
3061
|
+
LINE_RE = /^((?: {2})*)\[([0-9a-f]+)\] ([a-zA-Z_][\w-]*)(?: ("(?:[^"\\]|\\.)*"))?(?: \[(.*)\])?$/;
|
|
3062
|
+
INTERACTIVE_ROLES = new Set([
|
|
3063
|
+
"button",
|
|
3064
|
+
"link",
|
|
3065
|
+
"textbox",
|
|
3066
|
+
"searchbox",
|
|
3067
|
+
"checkbox",
|
|
3068
|
+
"radio",
|
|
3069
|
+
"combobox",
|
|
3070
|
+
"listbox",
|
|
3071
|
+
"option",
|
|
3072
|
+
"slider",
|
|
3073
|
+
"spinbutton",
|
|
3074
|
+
"progressbar",
|
|
3075
|
+
"meter"
|
|
3076
|
+
]);
|
|
3077
|
+
STAMP_ONE_SOURCE = `function(){var a=${JSON.stringify(UID_STAMP_ATTR)};var e=this.getAttribute(a);if(e)return e;var s=Math.floor(Math.random()*0x100000000).toString(16).padStart(8,"0")+Math.floor(Math.random()*0x10000).toString(16).padStart(4,"0");this.setAttribute(a,s);return s;}`;
|
|
3078
|
+
KEY_VALUES = {
|
|
3079
|
+
enter: "",
|
|
3080
|
+
return: "",
|
|
3081
|
+
tab: "",
|
|
3082
|
+
escape: "",
|
|
3083
|
+
esc: "",
|
|
3084
|
+
backspace: "",
|
|
3085
|
+
delete: "",
|
|
3086
|
+
space: " ",
|
|
3087
|
+
arrowup: "",
|
|
3088
|
+
arrowdown: "",
|
|
3089
|
+
arrowleft: "",
|
|
3090
|
+
arrowright: "",
|
|
3091
|
+
up: "",
|
|
3092
|
+
down: "",
|
|
3093
|
+
left: "",
|
|
3094
|
+
right: "",
|
|
3095
|
+
home: "",
|
|
3096
|
+
end: "",
|
|
3097
|
+
pageup: "",
|
|
3098
|
+
pagedown: ""
|
|
3099
|
+
};
|
|
3100
|
+
MODIFIER_VALUES = { shift: "", control: "", ctrl: "", alt: "", meta: "", cmd: "", command: "" };
|
|
3101
|
+
BUTTON_CODES = { left: 0, middle: 1, right: 2 };
|
|
3102
|
+
ARM_SCROLL_SETTLE_WATCH_SOURCE = "function(){window.__cdpScrollSettle=new Promise((resolve)=>{let t;const done=()=>{window.removeEventListener('scroll',on,true);resolve(true);};" + "const on=()=>{clearTimeout(t);t=setTimeout(done,60);};window.addEventListener('scroll',on,true);t=setTimeout(done,60);setTimeout(done,500);});}";
|
|
3103
|
+
BIDI_CAPABILITIES = new Set([
|
|
3104
|
+
"emulate.deviceMetrics",
|
|
3105
|
+
"screenshot.fullPage",
|
|
3106
|
+
"screenshot.element",
|
|
3107
|
+
"screenshot.renderSize",
|
|
3108
|
+
"network.intercept",
|
|
3109
|
+
"locate.xpath"
|
|
3110
|
+
]);
|
|
3111
|
+
connections = new Map;
|
|
3112
|
+
slotHandles = new Map;
|
|
3113
|
+
muxes = new Map;
|
|
3114
|
+
joined = new Set;
|
|
3115
|
+
connecting = new Map;
|
|
3116
|
+
sessionEpochs = new Map;
|
|
3117
|
+
connEndpoints = new WeakMap;
|
|
3118
|
+
STALE_ID_HINT = " — the Firefox BiDi session was re-established after its previous holder went away, and Firefox " + "assigns new context ids per session — re-resolve the tab with list_pages (url:/title: selectors) " + "and claim it again";
|
|
3119
|
+
bidiBeaconScripts = new Map;
|
|
3120
|
+
BidiPageDriver = class BidiPageDriver {
|
|
3121
|
+
conn;
|
|
3122
|
+
contextId;
|
|
3123
|
+
info;
|
|
3124
|
+
browser;
|
|
3125
|
+
released = false;
|
|
3126
|
+
bodyCollector;
|
|
3127
|
+
constructor(conn, contextId, info, browser) {
|
|
3128
|
+
this.conn = conn;
|
|
3129
|
+
this.contextId = contextId;
|
|
3130
|
+
this.info = info;
|
|
3131
|
+
this.browser = browser;
|
|
3132
|
+
}
|
|
3133
|
+
async urlAndReadyState() {
|
|
3134
|
+
const raw = await this.conn.send("script.evaluate", {
|
|
3135
|
+
expression: "location.href + '\\u0000' + document.readyState",
|
|
3136
|
+
target: { context: this.contextId },
|
|
3137
|
+
awaitPromise: false
|
|
3138
|
+
}).then((r) => r.type === "success" ? String(deserializeRemote(r.result) ?? "") : "").catch(() => "");
|
|
3139
|
+
const [url = "", ready = ""] = raw.split("\x00");
|
|
3140
|
+
return [url, ready];
|
|
3141
|
+
}
|
|
3142
|
+
async settleAfterTraversal(beforeUrl, waitUntil, timeoutMs) {
|
|
3143
|
+
const ready = waitUntil === "domcontentloaded" ? ["interactive", "complete"] : ["complete"];
|
|
3144
|
+
const deadline = Date.now() + timeoutMs;
|
|
3145
|
+
for (;; ) {
|
|
3146
|
+
const [url, state] = await this.urlAndReadyState();
|
|
3147
|
+
if (url && url !== beforeUrl && ready.includes(state))
|
|
3148
|
+
return { waitedFor: waitUntil, url };
|
|
3149
|
+
if (Date.now() >= deadline)
|
|
3150
|
+
return { waitedFor: "timeout", url: url || beforeUrl };
|
|
3151
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
3152
|
+
}
|
|
3153
|
+
}
|
|
3154
|
+
async navigate(opts) {
|
|
3155
|
+
const reload = opts.reload === true;
|
|
3156
|
+
const history = opts.history;
|
|
3157
|
+
if (!reload && !history && !opts.url)
|
|
3158
|
+
throw driverError("page-error", "navigate: 'url' is required unless reload:true");
|
|
3159
|
+
const waitUntil = opts.waitUntil ?? "load";
|
|
3160
|
+
const wait = waitUntil === "domcontentloaded" ? "interactive" : "complete";
|
|
3161
|
+
const sendOpts = opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {};
|
|
3162
|
+
try {
|
|
3163
|
+
if (reload) {
|
|
3164
|
+
if (opts.ignoreCache === true) {
|
|
3165
|
+
throw driverError("unsupported", "navigate: ignoreCache:true (hard reload) is not supported on the Firefox WebDriver-BiDi backend — Firefox rejects the ignoreCache argument outright. A plain reload (reload:true, no ignoreCache) works.");
|
|
3166
|
+
}
|
|
3167
|
+
const r = await this.conn.send("browsingContext.reload", { context: this.contextId, wait }, sendOpts);
|
|
3168
|
+
return { url: r.url, contextId: this.contextId, reloaded: true, waitedFor: waitUntil };
|
|
3169
|
+
}
|
|
3170
|
+
if (history) {
|
|
3171
|
+
const [beforeUrl] = await this.urlAndReadyState();
|
|
3172
|
+
await this.conn.send("browsingContext.traverseHistory", { context: this.contextId, delta: history === "back" ? -1 : 1 }, sendOpts).catch((e) => {
|
|
3173
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
3174
|
+
if (/no such history entry|history/i.test(msg))
|
|
3175
|
+
throw driverError("page-error", `navigate: no history entry to go ${history} to (${msg})`);
|
|
3176
|
+
throw e;
|
|
3177
|
+
});
|
|
3178
|
+
const settled = await this.settleAfterTraversal(beforeUrl, waitUntil, opts.timeoutMs ?? 15000);
|
|
3179
|
+
return { url: settled.url, contextId: this.contextId, traversed: history, waitedFor: settled.waitedFor };
|
|
3180
|
+
}
|
|
3181
|
+
const r = await this.conn.send("browsingContext.navigate", { context: this.contextId, url: opts.url, wait }, sendOpts);
|
|
3182
|
+
return { url: r.url, contextId: this.contextId, waitedFor: waitUntil };
|
|
3183
|
+
} catch (e) {
|
|
3184
|
+
throw mapBidiError(e);
|
|
3185
|
+
}
|
|
3186
|
+
}
|
|
3187
|
+
async waitForText(text, timeoutMs = 15000, pollMs = 250) {
|
|
3188
|
+
const start = Date.now();
|
|
3189
|
+
const expr = `(() => { const b = document.body; return !!b && typeof b.innerText === 'string' && b.innerText.includes(${JSON.stringify(text)}); })()`;
|
|
3190
|
+
for (;; ) {
|
|
3191
|
+
const found = await this.conn.send("script.evaluate", { expression: expr, target: { context: this.contextId }, awaitPromise: true }).then((r) => r.type === "success" && deserializeRemote(r.result) === true).catch(() => false);
|
|
3192
|
+
if (found)
|
|
3193
|
+
return { found: true, elapsedMs: Date.now() - start };
|
|
3194
|
+
if (Date.now() - start >= timeoutMs)
|
|
3195
|
+
throw driverError("timeout", `waitForText: '${text}' not found within ${timeoutMs}ms`);
|
|
3196
|
+
await new Promise((r) => setTimeout(r, Math.min(pollMs, Math.max(0, timeoutMs - (Date.now() - start)))));
|
|
3197
|
+
}
|
|
3198
|
+
}
|
|
3199
|
+
async callFunctionValue(functionDeclaration, args, thisRef) {
|
|
3200
|
+
try {
|
|
3201
|
+
const res = await this.conn.send("script.callFunction", {
|
|
3202
|
+
functionDeclaration,
|
|
3203
|
+
awaitPromise: true,
|
|
3204
|
+
target: { context: this.contextId },
|
|
3205
|
+
arguments: args.map(serializeArg),
|
|
3206
|
+
...thisRef ? { this: thisRef } : {}
|
|
3207
|
+
});
|
|
3208
|
+
if (res.type === "exception")
|
|
3209
|
+
throw driverError("page-error", exceptionMessage(res.exceptionDetails));
|
|
3210
|
+
return deserializeRemote(res.result);
|
|
3211
|
+
} catch (e) {
|
|
3212
|
+
throw isDriverError(e) ? e : mapBidiError(e);
|
|
3213
|
+
}
|
|
3214
|
+
}
|
|
3215
|
+
async evaluate(expression, opts) {
|
|
3216
|
+
const awaitPromise = opts?.awaitPromise ?? true;
|
|
3217
|
+
if (opts?.args && opts.args.length > 0)
|
|
3218
|
+
return this.callFunctionValue(expression, [...opts.args]);
|
|
3219
|
+
try {
|
|
3220
|
+
const res = await this.conn.send("script.evaluate", { expression, awaitPromise, target: { context: this.contextId } });
|
|
3221
|
+
if (res.type === "exception")
|
|
3222
|
+
throw driverError("page-error", exceptionMessage(res.exceptionDetails));
|
|
3223
|
+
return deserializeRemote(res.result);
|
|
3224
|
+
} catch (e) {
|
|
3225
|
+
throw isDriverError(e) ? e : mapBidiError(e);
|
|
3226
|
+
}
|
|
3227
|
+
}
|
|
3228
|
+
async callOnElement(loc, functionDeclaration, args = []) {
|
|
3229
|
+
const ref = await resolveElementLocator(this.conn, this.contextId, loc);
|
|
3230
|
+
return this.callFunctionValue(functionDeclaration, [...args], ref);
|
|
3231
|
+
}
|
|
3232
|
+
async snapshot(opts) {
|
|
3233
|
+
const { snapshot } = await takeStampedSnapshot((src) => this.callFunctionValue(src, []));
|
|
3234
|
+
const nodes = [];
|
|
3235
|
+
for (const line of snapshot.split(`
|
|
3236
|
+
`)) {
|
|
3237
|
+
if (!line)
|
|
3238
|
+
continue;
|
|
3239
|
+
const n = parseSnapshotLine(line);
|
|
3240
|
+
if (n)
|
|
3241
|
+
nodes.push(n);
|
|
3242
|
+
}
|
|
3243
|
+
return opts?.interactiveOnly ? nodes.filter((n) => INTERACTIVE_ROLES.has(n.role)) : nodes;
|
|
3244
|
+
}
|
|
3245
|
+
async locate(loc) {
|
|
3246
|
+
if ("uid" in loc) {
|
|
3247
|
+
await this.resolve(loc.uid);
|
|
3248
|
+
return loc.uid;
|
|
3249
|
+
}
|
|
3250
|
+
const ref = await resolveElementLocator(this.conn, this.contextId, loc);
|
|
3251
|
+
const stamp = await this.callFunctionValue(STAMP_ONE_SOURCE, [], ref);
|
|
3252
|
+
if (typeof stamp !== "string")
|
|
3253
|
+
throw driverError("page-error", "stamping the located element did not return a string");
|
|
3254
|
+
return encodeUid(stamp);
|
|
3255
|
+
}
|
|
3256
|
+
async resolve(uid) {
|
|
3257
|
+
const stamp = decodeStamp(uid);
|
|
3258
|
+
const nodes = await locateNodes(this.conn, this.contextId, { type: "css", value: `[${UID_STAMP_ATTR}="${cssEscape(stamp)}"]` });
|
|
3259
|
+
if (!nodes.length)
|
|
3260
|
+
throw driverError("stale-uid", `uid does not resolve: ${uid}`);
|
|
3261
|
+
}
|
|
3262
|
+
async performActions(actions) {
|
|
3263
|
+
recordDispatch("firefox", this.contextId);
|
|
3264
|
+
try {
|
|
3265
|
+
await this.conn.send("input.performActions", { context: this.contextId, actions });
|
|
3266
|
+
} catch (e) {
|
|
3267
|
+
throw mapBidiError(e);
|
|
3268
|
+
}
|
|
3269
|
+
}
|
|
3270
|
+
async centerOf(ref) {
|
|
3271
|
+
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};}";
|
|
3272
|
+
const val = await this.callFunctionValue(fn, [], ref);
|
|
3273
|
+
if (!val || typeof val !== "object")
|
|
3274
|
+
throw driverError("page-error", "element has zero size / is not visible; cannot compute a click point");
|
|
3275
|
+
return val;
|
|
3276
|
+
}
|
|
3277
|
+
async hover(loc) {
|
|
3278
|
+
const ref = await resolveElementLocator(this.conn, this.contextId, loc);
|
|
3279
|
+
const { x, y } = await this.centerOf(ref);
|
|
3280
|
+
await this.performActions([{ type: "pointer", id: "cdp-mouse", actions: [{ type: "pointerMove", x, y, duration: 0 }] }]);
|
|
3281
|
+
return { x, y };
|
|
3282
|
+
}
|
|
3283
|
+
async click(loc, opts) {
|
|
3284
|
+
if (opts?.modifiers && opts.modifiers.length > 0) {
|
|
3285
|
+
throw driverError("unsupported", "click: 'modifiers' is not supported by the Firefox/BiDi driver; omit 'modifiers' or use --browser chrome for a modifier click.");
|
|
3286
|
+
}
|
|
3287
|
+
const ref = await resolveElementLocator(this.conn, this.contextId, loc);
|
|
3288
|
+
const { x, y } = await this.centerOf(ref);
|
|
3289
|
+
const button = BUTTON_CODES[opts?.button ?? "left"];
|
|
3290
|
+
const actions = [{ type: "pointerMove", x, y, duration: 0 }];
|
|
3291
|
+
for (let i = 0;i < (opts?.clickCount ?? 1); i++)
|
|
3292
|
+
actions.push({ type: "pointerDown", button }, { type: "pointerUp", button });
|
|
3293
|
+
await this.performActions([{ type: "pointer", id: "cdp-mouse", actions }]);
|
|
3294
|
+
return { x, y };
|
|
3295
|
+
}
|
|
3296
|
+
async scroll(anchor, opts) {
|
|
3297
|
+
const { x, y } = await this.resolveScrollPoint(anchor);
|
|
3298
|
+
const deltaX = Math.round(opts.deltaX ?? 0);
|
|
3299
|
+
const deltaY = Math.round(opts.deltaY ?? 0);
|
|
3300
|
+
await this.callFunctionValue(ARM_SCROLL_SETTLE_WATCH_SOURCE, []);
|
|
3301
|
+
const actions = [{ type: "scroll", x: Math.round(x), y: Math.round(y), deltaX, deltaY }];
|
|
3302
|
+
await this.performActions([{ type: "wheel", id: "cdp-wheel", actions }]);
|
|
3303
|
+
await this.callFunctionValue(AWAIT_SCROLL_SETTLE_SOURCE, []).catch(() => {
|
|
3304
|
+
return;
|
|
3305
|
+
});
|
|
3306
|
+
return { x, y };
|
|
3307
|
+
}
|
|
3308
|
+
async resolveScrollPoint(anchor) {
|
|
3309
|
+
if (anchor === undefined) {
|
|
3310
|
+
const v = await this.callFunctionValue("function(){return {x: window.innerWidth / 2, y: window.innerHeight / 2};}", []);
|
|
3311
|
+
if (!v || typeof v !== "object")
|
|
3312
|
+
throw driverError("page-error", "could not measure viewport for scroll center");
|
|
3313
|
+
return v;
|
|
3314
|
+
}
|
|
3315
|
+
if ("x" in anchor)
|
|
3316
|
+
return anchor;
|
|
3317
|
+
return this.centerOf(await resolveElementLocator(this.conn, this.contextId, anchor));
|
|
3318
|
+
}
|
|
3319
|
+
async drag(from, to, opts) {
|
|
3320
|
+
if (opts?.mode === "html5") {
|
|
3321
|
+
throw driverError("unsupported", "drag: mode:'html5' is not supported by the Firefox/BiDi driver (WebDriver BiDi has no drag-interception primitive); " + "omit 'mode' for the synthetic-mouse drag, or use --browser chrome for real HTML5 drag events.");
|
|
3322
|
+
}
|
|
3323
|
+
const fromPt = await this.centerOf(await resolveElementLocator(this.conn, this.contextId, from));
|
|
3324
|
+
const toPt = await this.resolveDragDestination(to, fromPt);
|
|
3325
|
+
const steps = Math.max(1, Math.trunc(opts?.steps ?? 2));
|
|
3326
|
+
const actions = [
|
|
3327
|
+
{ type: "pointerMove", x: fromPt.x, y: fromPt.y, duration: 0 },
|
|
3328
|
+
{ type: "pointerDown", button: 0 }
|
|
3329
|
+
];
|
|
3330
|
+
for (const pt of interpolatePoints(fromPt, toPt, steps))
|
|
3331
|
+
actions.push({ type: "pointerMove", x: pt.x, y: pt.y, duration: 50 });
|
|
3332
|
+
actions.push({ type: "pointerUp", button: 0 });
|
|
3333
|
+
await this.performActions([{ type: "pointer", id: "cdp-mouse", actions }]);
|
|
3334
|
+
return { from: fromPt, to: toPt };
|
|
3335
|
+
}
|
|
3336
|
+
async resolveDragDestination(to, fromPt) {
|
|
3337
|
+
if ("dx" in to)
|
|
3338
|
+
return { x: fromPt.x + to.dx, y: fromPt.y + to.dy };
|
|
3339
|
+
if ("x" in to)
|
|
3340
|
+
return to;
|
|
3341
|
+
return this.centerOf(await resolveElementLocator(this.conn, this.contextId, to));
|
|
3342
|
+
}
|
|
3343
|
+
async sendKeys(text) {
|
|
3344
|
+
if (!text.length)
|
|
3345
|
+
return;
|
|
3346
|
+
const actions = [];
|
|
3347
|
+
for (const ch of text)
|
|
3348
|
+
actions.push({ type: "keyDown", value: ch }, { type: "keyUp", value: ch });
|
|
3349
|
+
await this.performActions([{ type: "key", id: "cdp-keyboard", actions }]);
|
|
3350
|
+
}
|
|
3351
|
+
static SELECT_SOURCE = "function(v){var found=false;for(var i=0;i<this.options.length;i++){if(this.options[i].value===v){this.selectedIndex=i;found=true;break;}}if(!found)this.value=v;this.dispatchEvent(new Event('input',{bubbles:true}));this.dispatchEvent(new Event('change',{bubbles:true}));}";
|
|
3352
|
+
static INSERT_TEXT_SOURCE = "function(v,collapseAtEnd){this.focus();var r=document.createRange();" + "r.selectNodeContents(this);if(collapseAtEnd)r.collapse(false);" + "var s=window.getSelection();s.removeAllRanges();s.addRange(r);" + "return document.execCommand('insertText', false, v);}";
|
|
3353
|
+
static IS_CONTENT_EDITABLE = "function(){return this.isContentEditable===true;}";
|
|
3354
|
+
async setValue(loc, value) {
|
|
3355
|
+
const ref = await resolveElementLocator(this.conn, this.contextId, loc);
|
|
3356
|
+
const isSelect = await this.callFunctionValue("function(){return this.tagName==='SELECT';}", [], ref);
|
|
3357
|
+
if (isSelect === true) {
|
|
3358
|
+
await this.callFunctionValue(BidiPageDriver.SELECT_SOURCE, [value], ref);
|
|
3359
|
+
return;
|
|
3360
|
+
}
|
|
3361
|
+
const isContentEditable = await this.callFunctionValue(BidiPageDriver.IS_CONTENT_EDITABLE, [], ref);
|
|
3362
|
+
if (isContentEditable === true) {
|
|
3363
|
+
await this.callFunctionValue(BidiPageDriver.INSERT_TEXT_SOURCE, [value, false], ref);
|
|
3364
|
+
return;
|
|
3365
|
+
}
|
|
3366
|
+
const clear = "function(){this.focus&&this.focus();if('value' in this){this.value='';this.dispatchEvent(new Event('input',{bubbles:true}));}}";
|
|
3367
|
+
await this.callFunctionValue(clear, [], ref);
|
|
3368
|
+
await this.sendKeys(value);
|
|
3369
|
+
}
|
|
3370
|
+
async typeText(loc, text) {
|
|
3371
|
+
const ref = await resolveElementLocator(this.conn, this.contextId, loc);
|
|
3372
|
+
const isContentEditable = await this.callFunctionValue(BidiPageDriver.IS_CONTENT_EDITABLE, [], ref);
|
|
3373
|
+
if (isContentEditable === true) {
|
|
3374
|
+
await this.callFunctionValue(BidiPageDriver.INSERT_TEXT_SOURCE, [text, true], ref);
|
|
3375
|
+
return;
|
|
3376
|
+
}
|
|
3377
|
+
await this.callFunctionValue("function(){this.focus&&this.focus();}", [], ref);
|
|
3378
|
+
await this.sendKeys(text);
|
|
3379
|
+
}
|
|
3380
|
+
async pressKey(press) {
|
|
3381
|
+
const value = KEY_VALUES[press.key.toLowerCase()] ?? (press.key.length === 1 ? press.key : undefined);
|
|
3382
|
+
if (value === undefined)
|
|
3383
|
+
throw driverError("page-error", `unknown key '${press.key}' (use a named key like Enter/Tab/ArrowDown or a single character)`);
|
|
3384
|
+
const mods = [];
|
|
3385
|
+
for (const m of press.modifiers ?? []) {
|
|
3386
|
+
const v = MODIFIER_VALUES[m.toLowerCase()];
|
|
3387
|
+
if (v === undefined)
|
|
3388
|
+
throw driverError("page-error", `unknown modifier '${m}'`);
|
|
3389
|
+
mods.push(v);
|
|
3390
|
+
}
|
|
3391
|
+
const actions = [...mods.map((v) => ({ type: "keyDown", value: v })), { type: "keyDown", value }, { type: "keyUp", value }, ...[...mods].reverse().map((v) => ({ type: "keyUp", value: v }))];
|
|
3392
|
+
await this.performActions([{ type: "key", id: "cdp-keyboard", actions }]);
|
|
3393
|
+
}
|
|
3394
|
+
async setFiles(loc, files) {
|
|
3395
|
+
const ref = await resolveElementLocator(this.conn, this.contextId, loc);
|
|
3396
|
+
try {
|
|
3397
|
+
await this.conn.send("input.setFiles", { context: this.contextId, element: ref, files: [...files] });
|
|
3398
|
+
} catch (e) {
|
|
3399
|
+
throw mapBidiError(e);
|
|
3400
|
+
}
|
|
3401
|
+
}
|
|
3402
|
+
async screenshot(opts, _consumeBands) {
|
|
3403
|
+
const renderSize = opts?.renderWidth !== undefined && opts?.renderHeight !== undefined ? { width: opts.renderWidth, height: opts.renderHeight } : undefined;
|
|
3404
|
+
if (!renderSize)
|
|
3405
|
+
return this.capture(opts);
|
|
3406
|
+
const previous = await readViewportRecord(this.browser.scheme, this.contextId);
|
|
3407
|
+
try {
|
|
3408
|
+
await this.conn.send("browsingContext.setViewport", { context: this.contextId, viewport: { width: renderSize.width, height: renderSize.height } });
|
|
3409
|
+
} catch (e) {
|
|
3410
|
+
throw mapBidiError(e);
|
|
3411
|
+
}
|
|
3412
|
+
let shot;
|
|
3413
|
+
let captureError;
|
|
3414
|
+
try {
|
|
3415
|
+
shot = await this.capture(opts);
|
|
3416
|
+
} catch (e) {
|
|
3417
|
+
captureError = e;
|
|
3418
|
+
}
|
|
3419
|
+
let restoreError;
|
|
3420
|
+
try {
|
|
3421
|
+
if (previous) {
|
|
3422
|
+
await this.conn.send("browsingContext.setViewport", {
|
|
3423
|
+
context: this.contextId,
|
|
3424
|
+
viewport: { width: previous.width, height: previous.height },
|
|
3425
|
+
...previous.deviceScaleFactor != null ? { devicePixelRatio: previous.deviceScaleFactor } : {}
|
|
3426
|
+
});
|
|
3427
|
+
} else {
|
|
3428
|
+
await this.conn.send("browsingContext.setViewport", { context: this.contextId, viewport: null });
|
|
3429
|
+
}
|
|
3430
|
+
} catch (e) {
|
|
3431
|
+
restoreError = e instanceof Error ? e.message : String(e);
|
|
3432
|
+
}
|
|
3433
|
+
if (!shot) {
|
|
3434
|
+
if (restoreError !== undefined && captureError instanceof Error) {
|
|
3435
|
+
captureError.message = `${captureError.message} ${renderRestoreFailureNote(renderSize, restoreError)}`;
|
|
3436
|
+
}
|
|
3437
|
+
throw captureError;
|
|
3438
|
+
}
|
|
3439
|
+
const result = { ...shot, renderSize, renderRestored: restoreError === undefined };
|
|
3440
|
+
if (restoreError !== undefined)
|
|
3441
|
+
result.renderRestoreError = restoreError;
|
|
3442
|
+
return result;
|
|
3443
|
+
}
|
|
3444
|
+
async capture(opts) {
|
|
3445
|
+
if (opts?.scale !== undefined && opts.scale !== 1) {
|
|
3446
|
+
throw driverError("unsupported", `screenshot scale ${opts.scale} is not supported by this backend (WebDriver BiDi's ` + "browsingContext.captureScreenshot has no scale parameter). Call emulate with " + `deviceScaleFactor:${opts.scale} first and capture at scale 1, or use --browser chrome.`);
|
|
3447
|
+
}
|
|
3448
|
+
if (opts?.tile === true) {
|
|
3449
|
+
throw driverError("unsupported", "screenshot tile:true is not supported by this backend: band tiling is declared only where the band " + "arithmetic has been measured against a real browser, and Firefox was not driven for it " + `(browsingContext.captureScreenshot's clip:{type:"box"} is the primitive it would ride, untested). ` + "Capture a smaller region or a single element, or use --browser chrome for a tiled capture.");
|
|
3450
|
+
}
|
|
3451
|
+
const format = opts?.format ?? "png";
|
|
3452
|
+
const params = {
|
|
3453
|
+
context: this.contextId,
|
|
3454
|
+
format: { type: format === "jpeg" ? "image/jpeg" : "image/png", ...format === "jpeg" ? { quality: (opts?.quality ?? 80) / 100 } : {} }
|
|
3455
|
+
};
|
|
3456
|
+
if (opts?.clip)
|
|
3457
|
+
params.clip = { type: "element", element: await resolveElementLocator(this.conn, this.contextId, opts.clip) };
|
|
3458
|
+
else if (opts?.fullPage)
|
|
3459
|
+
params.origin = "document";
|
|
3460
|
+
try {
|
|
3461
|
+
const { data } = await this.conn.send("browsingContext.captureScreenshot", params);
|
|
3462
|
+
return { data: new Uint8Array(Buffer.from(data, "base64")), format };
|
|
3463
|
+
} catch (e) {
|
|
3464
|
+
throw mapBidiError(e);
|
|
3465
|
+
}
|
|
3466
|
+
}
|
|
3467
|
+
async emulate(opts) {
|
|
3468
|
+
if (opts.clearOverrides) {
|
|
3469
|
+
await this.conn.send("emulation.setUserAgentOverride", { userAgent: null, contexts: [this.contextId] }).catch(() => {
|
|
3470
|
+
return;
|
|
3471
|
+
});
|
|
3472
|
+
await this.conn.send("browsingContext.setViewport", { context: this.contextId, viewport: null, devicePixelRatio: null }).catch(() => {
|
|
3473
|
+
return;
|
|
3474
|
+
});
|
|
3475
|
+
await clearViewportRecord(this.browser.scheme, this.contextId);
|
|
3476
|
+
return { applied: [] };
|
|
3477
|
+
}
|
|
3478
|
+
const applied = [];
|
|
3479
|
+
try {
|
|
3480
|
+
if (opts.width != null && opts.height != null) {
|
|
3481
|
+
await this.conn.send("browsingContext.setViewport", { context: this.contextId, viewport: { width: opts.width, height: opts.height }, ...opts.deviceScaleFactor != null ? { devicePixelRatio: opts.deviceScaleFactor } : {} });
|
|
3482
|
+
await writeViewportRecord(this.browser.scheme, this.contextId, {
|
|
3483
|
+
width: opts.width,
|
|
3484
|
+
height: opts.height,
|
|
3485
|
+
...opts.deviceScaleFactor != null ? { deviceScaleFactor: opts.deviceScaleFactor } : {}
|
|
3486
|
+
});
|
|
3487
|
+
applied.push("deviceMetrics");
|
|
3488
|
+
}
|
|
3489
|
+
if (opts.userAgent != null) {
|
|
3490
|
+
await this.conn.send("emulation.setUserAgentOverride", { userAgent: opts.userAgent, contexts: [this.contextId] });
|
|
3491
|
+
applied.push("userAgent");
|
|
3492
|
+
}
|
|
3493
|
+
} catch (e) {
|
|
3494
|
+
throw mapBidiError(e);
|
|
3495
|
+
}
|
|
3496
|
+
return { applied };
|
|
3497
|
+
}
|
|
3498
|
+
static EVENT_MAP = {
|
|
3499
|
+
navigated: "browsingContext.navigationCommitted",
|
|
3500
|
+
console: "log.entryAdded",
|
|
3501
|
+
"network.request": "network.beforeRequestSent",
|
|
3502
|
+
"network.response": "network.responseCompleted",
|
|
3503
|
+
dialog: "browsingContext.userPromptOpened",
|
|
3504
|
+
"page.closed": "browsingContext.contextDestroyed"
|
|
3505
|
+
};
|
|
3506
|
+
contextMatches(event, payload) {
|
|
3507
|
+
if (event === "console")
|
|
3508
|
+
return true;
|
|
3509
|
+
return payload.context === undefined || payload.context === this.contextId;
|
|
3510
|
+
}
|
|
3511
|
+
on(event, handler) {
|
|
3512
|
+
return this.conn.on(BidiPageDriver.EVENT_MAP[event], (params) => {
|
|
3513
|
+
const p = params;
|
|
3514
|
+
if (this.contextMatches(event, p))
|
|
3515
|
+
handler(p);
|
|
3516
|
+
});
|
|
3517
|
+
}
|
|
3518
|
+
async waitForEvent(event, predicate, timeoutMs) {
|
|
3519
|
+
try {
|
|
3520
|
+
const p = await this.conn.waitFor(BidiPageDriver.EVENT_MAP[event], (params) => {
|
|
3521
|
+
const p = params;
|
|
3522
|
+
return this.contextMatches(event, p) && (!predicate || predicate(p));
|
|
3523
|
+
}, timeoutMs);
|
|
3524
|
+
return p;
|
|
3525
|
+
} catch (e) {
|
|
3526
|
+
throw mapBidiError(e);
|
|
3527
|
+
}
|
|
3528
|
+
}
|
|
3529
|
+
async handleOneDialog(accept, promptText, timeoutMs) {
|
|
3530
|
+
const opened = await this.conn.waitFor("browsingContext.userPromptOpened", (p) => p.context === this.contextId, timeoutMs).catch(() => {
|
|
3531
|
+
throw driverError("timeout", `handleDialog: no dialog opened within ${timeoutMs}ms`);
|
|
3532
|
+
});
|
|
3533
|
+
try {
|
|
3534
|
+
await this.conn.send("browsingContext.handleUserPrompt", { context: this.contextId, accept, ...promptText !== undefined ? { userText: promptText } : {} });
|
|
3535
|
+
} catch (e) {
|
|
3536
|
+
throw mapBidiError(e);
|
|
3537
|
+
}
|
|
3538
|
+
return {
|
|
3539
|
+
type: opened.type,
|
|
3540
|
+
message: opened.message,
|
|
3541
|
+
url: this.info.url,
|
|
3542
|
+
...opened.defaultValue !== undefined ? { defaultPrompt: opened.defaultValue } : {},
|
|
3543
|
+
accept,
|
|
3544
|
+
...promptText !== undefined ? { promptText } : {},
|
|
3545
|
+
handled: true
|
|
3546
|
+
};
|
|
3547
|
+
}
|
|
3548
|
+
async handleDialog(accept, promptText, opts) {
|
|
3549
|
+
if (opts?.autoMs !== undefined) {
|
|
3550
|
+
const deadline = Date.now() + opts.autoMs;
|
|
3551
|
+
const handled = [];
|
|
3552
|
+
while (Date.now() < deadline) {
|
|
3553
|
+
const remaining = deadline - Date.now();
|
|
3554
|
+
if (remaining <= 0)
|
|
3555
|
+
break;
|
|
3556
|
+
const d = await this.handleOneDialog(accept, promptText, Math.min(250, remaining)).catch(() => {
|
|
3557
|
+
return;
|
|
3558
|
+
});
|
|
3559
|
+
if (d)
|
|
3560
|
+
handled.push(d);
|
|
3561
|
+
}
|
|
3562
|
+
return { handled, count: handled.length };
|
|
3563
|
+
}
|
|
3564
|
+
return this.handleOneDialog(accept, promptText, opts?.timeoutMs ?? 15000);
|
|
3565
|
+
}
|
|
3566
|
+
async getCookies() {
|
|
3567
|
+
try {
|
|
3568
|
+
const { cookies } = await this.conn.send("storage.getCookies", { partition: { type: "context", context: this.contextId } });
|
|
3569
|
+
return (cookies ?? []).map(normalizeBidiCookie);
|
|
3570
|
+
} catch (e) {
|
|
3571
|
+
throw mapBidiError(e);
|
|
3572
|
+
}
|
|
3573
|
+
}
|
|
3574
|
+
async setCookie(params) {
|
|
3575
|
+
const domain = params.domain ?? hostFromUrl(params.url, "set_cookie");
|
|
3576
|
+
try {
|
|
3577
|
+
await this.conn.send("storage.setCookie", {
|
|
3578
|
+
cookie: {
|
|
3579
|
+
name: params.name,
|
|
3580
|
+
value: { type: "string", value: params.value },
|
|
3581
|
+
domain,
|
|
3582
|
+
...params.path !== undefined ? { path: params.path } : {},
|
|
3583
|
+
...params.expires !== undefined ? { expiry: params.expires } : {},
|
|
3584
|
+
...params.httpOnly !== undefined ? { httpOnly: params.httpOnly } : {},
|
|
3585
|
+
...params.secure !== undefined ? { secure: params.secure } : {},
|
|
3586
|
+
...params.sameSite !== undefined ? { sameSite: params.sameSite } : {}
|
|
3587
|
+
},
|
|
3588
|
+
partition: { type: "context", context: this.contextId }
|
|
3589
|
+
});
|
|
3590
|
+
} catch (e) {
|
|
3591
|
+
throw mapBidiError(e);
|
|
3592
|
+
}
|
|
3593
|
+
}
|
|
3594
|
+
async deleteCookies(filter) {
|
|
3595
|
+
const domain = filter.domain ?? hostFromUrl(filter.url, "delete_cookies");
|
|
3596
|
+
try {
|
|
3597
|
+
await this.conn.send("storage.deleteCookies", {
|
|
3598
|
+
filter: {
|
|
3599
|
+
name: filter.name,
|
|
3600
|
+
domain,
|
|
3601
|
+
...filter.path !== undefined ? { path: filter.path } : {}
|
|
3602
|
+
},
|
|
3603
|
+
partition: { type: "context", context: this.contextId }
|
|
3604
|
+
});
|
|
3605
|
+
} catch (e) {
|
|
3606
|
+
throw mapBidiError(e);
|
|
3607
|
+
}
|
|
3608
|
+
}
|
|
3609
|
+
async startBodyCapture() {
|
|
3610
|
+
let collector;
|
|
3611
|
+
try {
|
|
3612
|
+
const res = await this.conn.send("network.addDataCollector", { dataTypes: ["response"], maxEncodedDataSize: 1e8, contexts: [this.contextId] });
|
|
3613
|
+
collector = res.collector;
|
|
3614
|
+
} catch (e) {
|
|
3615
|
+
throw mapBidiError(e);
|
|
3616
|
+
}
|
|
3617
|
+
this.bodyCollector = collector;
|
|
3618
|
+
return async () => {
|
|
3619
|
+
await this.conn.send("network.removeDataCollector", { collector }).catch(() => {
|
|
3620
|
+
return;
|
|
3621
|
+
});
|
|
3622
|
+
if (this.bodyCollector === collector)
|
|
3623
|
+
this.bodyCollector = undefined;
|
|
3624
|
+
};
|
|
3625
|
+
}
|
|
3626
|
+
async getResponseBody(requestId) {
|
|
3627
|
+
if (!this.bodyCollector)
|
|
3628
|
+
throw driverError("unsupported", "getResponseBody: call startBodyCapture() first (network.responseBodyPostHoc is not supported)");
|
|
3629
|
+
try {
|
|
3630
|
+
const { bytes } = await this.conn.send("network.getData", { dataType: "response", collector: this.bodyCollector, request: requestId });
|
|
3631
|
+
return bytes.type === "base64" ? { body: bytes.value, base64Encoded: true } : { body: bytes.value, base64Encoded: false };
|
|
3632
|
+
} catch (e) {
|
|
3633
|
+
throw mapBidiError(e);
|
|
3634
|
+
}
|
|
3635
|
+
}
|
|
3636
|
+
async intercept(rules) {
|
|
3637
|
+
let intercept;
|
|
3638
|
+
try {
|
|
3639
|
+
const res = await this.conn.send("network.addIntercept", { phases: ["beforeRequestSent"], contexts: [this.contextId] });
|
|
3640
|
+
intercept = res.intercept;
|
|
3641
|
+
} catch (e) {
|
|
3642
|
+
throw mapBidiError(e);
|
|
3643
|
+
}
|
|
3644
|
+
const off = this.conn.on("network.beforeRequestSent", (params) => {
|
|
3645
|
+
(async () => {
|
|
3646
|
+
const req = params.request;
|
|
3647
|
+
const rule = selectRule(rules, req.url, req.method);
|
|
3648
|
+
if (!rule) {
|
|
3649
|
+
await this.conn.send("network.continueRequest", { request: req.request }).catch(() => {
|
|
3650
|
+
return;
|
|
3651
|
+
});
|
|
3652
|
+
return;
|
|
3653
|
+
}
|
|
3654
|
+
if (rule.delayMs)
|
|
3655
|
+
await new Promise((r) => setTimeout(r, rule.delayMs));
|
|
3656
|
+
const act = effectiveAction(rule, Math.random());
|
|
3657
|
+
if (act === "fulfill") {
|
|
3658
|
+
const built = buildFulfillParams(req.request, rule);
|
|
3659
|
+
await this.conn.send("network.provideResponse", {
|
|
3660
|
+
request: req.request,
|
|
3661
|
+
statusCode: built.responseCode,
|
|
3662
|
+
headers: built.responseHeaders.map((h) => ({ name: h.name, value: { type: "string", value: h.value } })),
|
|
3663
|
+
body: { type: "base64", value: built.body }
|
|
3664
|
+
}).catch(() => {
|
|
3665
|
+
return;
|
|
3666
|
+
});
|
|
3667
|
+
} else if (act === "fail") {
|
|
3668
|
+
await this.conn.send("network.failRequest", { request: req.request }).catch(() => {
|
|
3669
|
+
return;
|
|
3670
|
+
});
|
|
3671
|
+
} else {
|
|
3672
|
+
await this.conn.send("network.continueRequest", { request: req.request }).catch(() => {
|
|
3673
|
+
return;
|
|
3674
|
+
});
|
|
3675
|
+
}
|
|
3676
|
+
})();
|
|
3677
|
+
});
|
|
3678
|
+
return async () => {
|
|
3679
|
+
off();
|
|
3680
|
+
await this.conn.send("network.removeIntercept", { intercept }).catch(() => {
|
|
3681
|
+
return;
|
|
3682
|
+
});
|
|
3683
|
+
};
|
|
3684
|
+
}
|
|
3685
|
+
async release() {
|
|
3686
|
+
if (this.released)
|
|
3687
|
+
return;
|
|
3688
|
+
this.released = true;
|
|
3689
|
+
this.conn.release();
|
|
3690
|
+
}
|
|
3691
|
+
};
|
|
3692
|
+
});
|
|
3693
|
+
|
|
3694
|
+
// src/bidi/mux-daemon.ts
|
|
3695
|
+
init_session_lease();
|
|
3696
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
3697
|
+
import { dirname } from "node:path";
|
|
3698
|
+
var DEFAULT_IDLE_MS = 15000;
|
|
3699
|
+
var CLIENT_POLL_MS = 500;
|
|
3700
|
+
var DISPOSE_TIMEOUT_MS = 1e4;
|
|
3701
|
+
var sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
3702
|
+
var endpoint = process.argv[2];
|
|
3703
|
+
var spawnerPid = process.argv[3] ?? "?";
|
|
3704
|
+
function idleMs() {
|
|
3705
|
+
const raw = Number(process.env.CDP_FIREFOX_MUX_IDLE_MS);
|
|
3706
|
+
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_IDLE_MS;
|
|
3707
|
+
}
|
|
3708
|
+
function log(line) {
|
|
3709
|
+
if (!endpoint)
|
|
3710
|
+
return;
|
|
3711
|
+
try {
|
|
3712
|
+
const file = muxDaemonLogFile(endpoint);
|
|
3713
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
3714
|
+
appendFileSync(file, `${new Date().toISOString()} pid=${process.pid} ${line}
|
|
3715
|
+
`);
|
|
3716
|
+
} catch {}
|
|
3717
|
+
}
|
|
3718
|
+
function fail(reason) {
|
|
3719
|
+
log(`EXIT(1) ${reason}`);
|
|
3720
|
+
process.exit(1);
|
|
3721
|
+
}
|
|
3722
|
+
async function main() {
|
|
3723
|
+
if (!endpoint)
|
|
3724
|
+
fail("no endpoint argument");
|
|
3725
|
+
process.env.CDP_FIREFOX_MUX = "host";
|
|
3726
|
+
log(`start endpoint=${endpoint} spawner=${spawnerPid} idleMs=${idleMs()}`);
|
|
3727
|
+
await Promise.resolve().then(() => init_driver2());
|
|
3728
|
+
let hosted;
|
|
3729
|
+
try {
|
|
3730
|
+
hosted = await hostFirefoxMux(endpoint, { label: `mux-daemon(spawned by pid ${spawnerPid})` });
|
|
3731
|
+
} catch (e) {
|
|
3732
|
+
fail(`could not host: ${e instanceof Error ? e.message : String(e)}`);
|
|
3733
|
+
}
|
|
3734
|
+
log(`advertised mux=${hosted.mux.endpoint}`);
|
|
3735
|
+
let shuttingDown = false;
|
|
3736
|
+
const shutdown = async (reason) => {
|
|
3737
|
+
if (shuttingDown)
|
|
3738
|
+
return;
|
|
3739
|
+
shuttingDown = true;
|
|
3740
|
+
clearInterval(timer);
|
|
3741
|
+
log(`shutdown begin: ${reason}`);
|
|
3742
|
+
await Promise.race([hosted.dispose().catch(() => {
|
|
3743
|
+
return;
|
|
3744
|
+
}), sleep3(DISPOSE_TIMEOUT_MS)]);
|
|
3745
|
+
log(`EXIT(0) ${reason}`);
|
|
3746
|
+
process.exit(0);
|
|
3747
|
+
};
|
|
3748
|
+
let idleSince = Date.now();
|
|
3749
|
+
const timer = setInterval(() => {
|
|
3750
|
+
if (shuttingDown)
|
|
3751
|
+
return;
|
|
3752
|
+
if (!hosted.conn.isOpen) {
|
|
3753
|
+
shutdown("upstream Firefox connection closed");
|
|
3754
|
+
return;
|
|
3755
|
+
}
|
|
3756
|
+
if (hosted.mux.clients() > 0) {
|
|
3757
|
+
idleSince = undefined;
|
|
3758
|
+
return;
|
|
3759
|
+
}
|
|
3760
|
+
if (idleSince === undefined)
|
|
3761
|
+
idleSince = Date.now();
|
|
3762
|
+
if (Date.now() - idleSince >= idleMs())
|
|
3763
|
+
shutdown(`idle for ${idleMs()}ms with no clients`);
|
|
3764
|
+
}, Math.max(50, Math.min(CLIENT_POLL_MS, Math.floor(idleMs() / 4))));
|
|
3765
|
+
for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) {
|
|
3766
|
+
process.on(sig, () => void shutdown(`signal ${sig}`));
|
|
3767
|
+
}
|
|
3768
|
+
process.on("uncaughtException", (e) => {
|
|
3769
|
+
log(`uncaught: ${e instanceof Error ? e.message : String(e)}`);
|
|
3770
|
+
shutdown("uncaught exception");
|
|
3771
|
+
});
|
|
3772
|
+
}
|
|
3773
|
+
main().catch((e) => fail(`startup threw: ${e instanceof Error ? e.message : String(e)}`));
|