barebrowse 0.14.0 → 0.17.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 +157 -0
- package/README.md +33 -1
- package/barebrowse.context.md +5 -2
- package/cli.js +10 -2
- package/mcp-server.js +14 -2
- package/package.json +1 -1
- package/src/auth.js +27 -13
- package/src/ax-snapshot.js +287 -0
- package/src/bidi.js +143 -0
- package/src/consent-firefox.js +125 -0
- package/src/consent-patterns.js +154 -0
- package/src/consent.js +5 -144
- package/src/daemon.js +162 -51
- package/src/firefox-page.js +445 -0
- package/src/firefox.js +203 -0
- package/src/index.js +88 -6
- package/src/network-idle.js +66 -28
- package/src/readable.js +32 -20
- package/src/stealth-firefox.js +42 -0
- package/src/stealth.js +107 -69
- package/types/auth.d.ts +18 -1
- package/types/ax-snapshot.d.ts +35 -0
- package/types/bidi.d.ts +10 -0
- package/types/consent-firefox.d.ts +9 -0
- package/types/consent-patterns.d.ts +12 -0
- package/types/daemon.d.ts +34 -0
- package/types/firefox-page.d.ts +21 -0
- package/types/firefox.d.ts +41 -0
- package/types/index.d.ts +13 -2
- package/types/network-idle.d.ts +16 -8
- package/types/readable.d.ts +8 -0
- package/types/stealth-firefox.d.ts +9 -0
- package/types/stealth.d.ts +31 -0
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* firefox-page.js — A barebrowse page object backed by WebDriver BiDi (Firefox).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the core of connect()'s CDP page surface — goto, snapshot, click,
|
|
5
|
+
* type, press, scroll, hover, readable, injectCookies, close — but every
|
|
6
|
+
* operation goes over BiDi instead of CDP. The tree that snapshot() feeds to
|
|
7
|
+
* prune.js/aria.js is reconstructed in-page by ax-snapshot.js (BiDi has no
|
|
8
|
+
* getFullAXTree), and refs resolve to elements through the data-bb-ref
|
|
9
|
+
* attribute those snapshots stamp on.
|
|
10
|
+
*
|
|
11
|
+
* Interactions use faithful BiDi input.performActions with an *element origin*
|
|
12
|
+
* (pointerMove auto-scrolls the element into view, then real pointerDown/Up
|
|
13
|
+
* fire) rather than a synthetic el.click(), so pages that need genuine mouse
|
|
14
|
+
* events behave. Refs are resolved to BiDi element sharedIds per context, so
|
|
15
|
+
* clicks land in the right frame.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { formatTree } from './aria.js';
|
|
19
|
+
import { prune as pruneTree } from './prune.js';
|
|
20
|
+
import { axSnapshotExpression, REF_ATTR } from './ax-snapshot.js';
|
|
21
|
+
import { EXTRACT_EXPRESSION, finalizeReadable } from './readable.js';
|
|
22
|
+
import { scopedCookiesForUrl } from './auth.js';
|
|
23
|
+
import { assertNavigable, assertUploadAllowed } from './url-guard.js';
|
|
24
|
+
import { dismissConsentFirefox } from './consent-firefox.js';
|
|
25
|
+
import { waitForNetworkIdleBiDi } from './network-idle.js';
|
|
26
|
+
|
|
27
|
+
/** BiDi/WebDriver normalized key values for named keys (U+E000 block). */
|
|
28
|
+
const BIDI_KEYS = {
|
|
29
|
+
Enter: "\uE007", Tab: "\uE004", Escape: "\uE00C", Backspace: "\uE003",
|
|
30
|
+
Delete: "\uE017", ArrowUp: "\uE013", ArrowDown: "\uE015",
|
|
31
|
+
ArrowLeft: "\uE012", ArrowRight: "\uE014", Home: "\uE011", End: "\uE010",
|
|
32
|
+
PageUp: "\uE00E", PageDown: "\uE00F", Space: " ",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build a Firefox/BiDi-backed page object.
|
|
37
|
+
* @param {object} bidi - BiDi client from createBiDi()
|
|
38
|
+
* @param {object} [opts]
|
|
39
|
+
* @param {'act'|'browse'|'navigate'|'full'|'read'} [opts.pruneMode='act'] - Default prune mode.
|
|
40
|
+
* @param {{allowLocalUrls?: boolean, blockPrivateNetwork?: boolean}} [opts.urlGuard] - Navigation safety policy, applied on every goto().
|
|
41
|
+
* @param {string} [opts.uploadDir] - When set, upload() rejects files outside this dir.
|
|
42
|
+
* @param {boolean} [opts.incognito=false] - Clean session: injectCookies() is a no-op.
|
|
43
|
+
* @param {boolean} [opts.consent=true] - Auto-dismiss cookie consent dialogs after goto().
|
|
44
|
+
* @returns {Promise<object>} page object
|
|
45
|
+
*/
|
|
46
|
+
export async function createFirefoxPage(bidi, opts = {}) {
|
|
47
|
+
const defaultPruneMode = opts.pruneMode || 'act';
|
|
48
|
+
const urlGuard = opts.urlGuard || {};
|
|
49
|
+
const uploadDir = opts.uploadDir || null;
|
|
50
|
+
const incognito = !!opts.incognito;
|
|
51
|
+
const consent = opts.consent !== false;
|
|
52
|
+
// The active browsing context. Starts at the initial tab; switchTab() points
|
|
53
|
+
// it at another top-level context, so it's mutable and read via a getter.
|
|
54
|
+
const { contexts } = await bidi.send('browsingContext.getTree', {});
|
|
55
|
+
let topContext = contexts[0].context;
|
|
56
|
+
|
|
57
|
+
// ref (string int) → owning browsing-context id, rebuilt every snapshot so a
|
|
58
|
+
// click after a snapshot routes to the frame the element actually lives in.
|
|
59
|
+
let refContexts = new Map();
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Depth-first list of the ACTIVE tab's contexts (main frame + descendant
|
|
63
|
+
* frames). Scoped to `topContext` via getTree's `root` param — without it
|
|
64
|
+
* getTree returns every open tab flat, and after switchTab() to a non-first
|
|
65
|
+
* tab the positional iframe-splice in snapshot() grafts another tab's frame
|
|
66
|
+
* into the active tab (verified: INNER1 leaked into TAB2). Root-scoping keeps
|
|
67
|
+
* topContext at index 0 so the splice stays aligned and cross-tab-safe.
|
|
68
|
+
*/
|
|
69
|
+
async function allContexts() {
|
|
70
|
+
const { contexts: tree } = await bidi.send('browsingContext.getTree', { root: topContext });
|
|
71
|
+
const flat = [];
|
|
72
|
+
(function walk(nodes) {
|
|
73
|
+
for (const n of nodes) { flat.push(n.context); walk(n.children || []); }
|
|
74
|
+
})(tree);
|
|
75
|
+
return flat;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Build the spliced AX tree for the active tab (main frame + child frames)
|
|
80
|
+
* and (re)populate refContexts so a subsequent click routes to the right
|
|
81
|
+
* frame. Returns the top context's root node, or null if it couldn't be
|
|
82
|
+
* snapshotted. Shared by snapshot() and the consent auto-dismiss so the
|
|
83
|
+
* iframe-splice logic lives in one place.
|
|
84
|
+
*/
|
|
85
|
+
async function buildTree() {
|
|
86
|
+
const contextIds = await allContexts();
|
|
87
|
+
refContexts = new Map();
|
|
88
|
+
|
|
89
|
+
// Snapshot each context, assigning refs from a shared running counter so
|
|
90
|
+
// they're globally unique (matching CDP's flat integer refs).
|
|
91
|
+
let base = 0;
|
|
92
|
+
const treesByContext = new Map();
|
|
93
|
+
for (const ctx of contextIds) {
|
|
94
|
+
let raw;
|
|
95
|
+
try {
|
|
96
|
+
raw = await bidi.evaluate(ctx, axSnapshotExpression(base), false);
|
|
97
|
+
} catch { continue; } // frame navigated mid-snapshot — skip it
|
|
98
|
+
const { tree, count } = JSON.parse(raw);
|
|
99
|
+
for (let r = base + 1; r <= base + count; r++) refContexts.set(String(r), ctx);
|
|
100
|
+
base += count;
|
|
101
|
+
treesByContext.set(ctx, tree);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Splice each child frame's tree under an <iframe> (role 'Iframe')
|
|
105
|
+
// placeholder in its parent, matched by document order — BiDi getTree
|
|
106
|
+
// lists children in frame order, and the AX walk emits Iframe nodes in
|
|
107
|
+
// the same order.
|
|
108
|
+
const iframeNodes = (tree) => {
|
|
109
|
+
const out = [];
|
|
110
|
+
(function walk(n) { if (n.role === 'Iframe') out.push(n); for (const c of n.children) walk(c); })(tree);
|
|
111
|
+
return out;
|
|
112
|
+
};
|
|
113
|
+
for (let i = 1; i < contextIds.length; i++) {
|
|
114
|
+
const childTree = treesByContext.get(contextIds[i]);
|
|
115
|
+
if (!childTree) continue;
|
|
116
|
+
// Attach to the next unfilled Iframe placeholder in the top tree.
|
|
117
|
+
const holders = iframeNodes(treesByContext.get(topContext) || { role: '', children: [] });
|
|
118
|
+
const holder = holders[i - 1];
|
|
119
|
+
if (holder) holder.children = [childTree];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return treesByContext.get(topContext) || null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Resolve a ref to a BiDi element sharedId in its owning context. */
|
|
126
|
+
async function resolveRef(ref) {
|
|
127
|
+
const context = refContexts.get(String(ref));
|
|
128
|
+
if (!context) throw new Error(`Unknown ref: ${ref} (snapshot first, or it's stale)`);
|
|
129
|
+
const res = await bidi.send('script.evaluate', {
|
|
130
|
+
expression: `document.querySelector('[${REF_ATTR}="${ref}"]')`,
|
|
131
|
+
target: { context }, awaitPromise: false, resultOwnership: 'root',
|
|
132
|
+
});
|
|
133
|
+
if (res.type === 'exception' || !res.result || res.result.type !== 'node') {
|
|
134
|
+
throw new Error(`Could not resolve element for ref ${ref}`);
|
|
135
|
+
}
|
|
136
|
+
return { context, sharedId: res.result.sharedId };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function pointerClick(ref) {
|
|
140
|
+
const { context, sharedId } = await resolveRef(ref);
|
|
141
|
+
await bidi.send('input.performActions', {
|
|
142
|
+
context,
|
|
143
|
+
actions: [{
|
|
144
|
+
type: 'pointer', id: 'mouse', parameters: { pointerType: 'mouse' },
|
|
145
|
+
actions: [
|
|
146
|
+
{ type: 'pointerMove', x: 0, y: 0, origin: { type: 'element', element: { sharedId } } },
|
|
147
|
+
{ type: 'pointerDown', button: 0 },
|
|
148
|
+
{ type: 'pointerUp', button: 0 },
|
|
149
|
+
],
|
|
150
|
+
}],
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function keyType(context, text) {
|
|
155
|
+
const actions = [];
|
|
156
|
+
for (const ch of text) { actions.push({ type: 'keyDown', value: ch }, { type: 'keyUp', value: ch }); }
|
|
157
|
+
await bidi.send('input.performActions', {
|
|
158
|
+
context, actions: [{ type: 'key', id: 'kbd', actions }],
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Reject if `p` doesn't settle within `ms`. BiDi commands have no built-in
|
|
164
|
+
* timeout, so a navigate/reload/traverse whose `wait:'complete'` never fires
|
|
165
|
+
* (a page that never loads) would otherwise hang the call forever.
|
|
166
|
+
*/
|
|
167
|
+
function withTimeout(p, ms, label) {
|
|
168
|
+
let timer;
|
|
169
|
+
const t = new Promise((_, rej) => { timer = setTimeout(() => rej(new Error(`${label} timed out after ${ms}ms`)), ms); });
|
|
170
|
+
return Promise.race([p, t]).finally(() => clearTimeout(timer));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const page = {
|
|
174
|
+
/** The BiDi escape hatch, analogous to connect()'s page.cdp. */
|
|
175
|
+
get bidi() { return bidi; },
|
|
176
|
+
/** The active browsing-context id (getter so it tracks switchTab). */
|
|
177
|
+
get context() { return topContext; },
|
|
178
|
+
|
|
179
|
+
async goto(url, timeout = 30000) {
|
|
180
|
+
// Same navigation guard the CDP path enforces — block file:/chrome:/
|
|
181
|
+
// view-source: and (optionally) private-network hosts before navigating.
|
|
182
|
+
assertNavigable(url, urlGuard);
|
|
183
|
+
await withTimeout(
|
|
184
|
+
bidi.send('browsingContext.navigate', { context: topContext, url, wait: 'complete' }),
|
|
185
|
+
timeout, `goto(${url})`);
|
|
186
|
+
// Brief settle for dynamic/SPA content, matching the CDP path.
|
|
187
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
188
|
+
// Auto-dismiss cookie consent, mirroring the CDP path (index.js runs
|
|
189
|
+
// dismissConsent right after navigate). Best-effort: a failure here must
|
|
190
|
+
// never fail the navigation.
|
|
191
|
+
if (consent) {
|
|
192
|
+
try {
|
|
193
|
+
const root = await buildTree();
|
|
194
|
+
if (root) await dismissConsentFirefox(root, (ref) => pointerClick(ref));
|
|
195
|
+
} catch { /* consent dismissal is best-effort */ }
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
async snapshot(pruneOpts) {
|
|
200
|
+
const root = await buildTree();
|
|
201
|
+
if (!root) return '';
|
|
202
|
+
|
|
203
|
+
const pageUrl = await bidi.evaluate(topContext, 'location.href', false).catch(() => '');
|
|
204
|
+
const raw = formatTree(root);
|
|
205
|
+
if (pruneOpts === false) return `url: ${pageUrl}\n` + raw;
|
|
206
|
+
|
|
207
|
+
const mode = pruneOpts?.mode || defaultPruneMode;
|
|
208
|
+
const pruned = pruneTree(root, { mode });
|
|
209
|
+
const out = pruned ? formatTree(pruned) : '';
|
|
210
|
+
const stats = `url: ${pageUrl}\n${raw.length.toLocaleString()} chars → ${out.length.toLocaleString()} chars`
|
|
211
|
+
+ ` (${raw.length ? Math.round((1 - out.length / raw.length) * 100) : 0}% pruned)`;
|
|
212
|
+
return stats + '\n' + out;
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
async readable() {
|
|
216
|
+
const raw = await bidi.evaluate(topContext, `JSON.stringify(${EXTRACT_EXPRESSION})`, true);
|
|
217
|
+
return finalizeReadable(JSON.parse(raw));
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
async click(ref) { await pointerClick(ref); },
|
|
221
|
+
|
|
222
|
+
async type(ref, text, typeOpts = {}) {
|
|
223
|
+
const { context, sharedId } = await resolveRef(ref);
|
|
224
|
+
// Focus the field (and optionally clear it) in-page, then send real key
|
|
225
|
+
// events so input handlers fire.
|
|
226
|
+
await bidi.send('script.callFunction', {
|
|
227
|
+
functionDeclaration: `function(clear){ this.focus(); if(clear){ if('value' in this) this.value=''; else this.textContent=''; this.dispatchEvent(new Event('input',{bubbles:true})); } }`,
|
|
228
|
+
arguments: [{ type: 'boolean', value: !!typeOpts.clear }],
|
|
229
|
+
this: { sharedId },
|
|
230
|
+
target: { context }, awaitPromise: false,
|
|
231
|
+
});
|
|
232
|
+
await keyType(context, text);
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
async press(key) {
|
|
236
|
+
const value = BIDI_KEYS[key] || (key.length === 1 ? key : null);
|
|
237
|
+
if (!value) throw new Error(`Unknown key: "${key}". Valid: ${Object.keys(BIDI_KEYS).join(', ')}`);
|
|
238
|
+
await bidi.send('input.performActions', {
|
|
239
|
+
context: topContext,
|
|
240
|
+
actions: [{ type: 'key', id: 'kbd', actions: [{ type: 'keyDown', value }, { type: 'keyUp', value }] }],
|
|
241
|
+
});
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
async scroll(deltaY) {
|
|
245
|
+
await bidi.evaluate(topContext, `window.scrollBy(0, ${Number(deltaY)})`, false);
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
async hover(ref) {
|
|
249
|
+
const { context, sharedId } = await resolveRef(ref);
|
|
250
|
+
await bidi.send('input.performActions', {
|
|
251
|
+
context,
|
|
252
|
+
actions: [{
|
|
253
|
+
type: 'pointer', id: 'mouse', parameters: { pointerType: 'mouse' },
|
|
254
|
+
actions: [{ type: 'pointerMove', x: 0, y: 0, origin: { type: 'element', element: { sharedId } } }],
|
|
255
|
+
}],
|
|
256
|
+
});
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Inject cookies from the user's real browser into this Firefox session,
|
|
261
|
+
* via BiDi storage.setCookie. Scoped to the URL host via the SHARED
|
|
262
|
+
* scopedCookiesForUrl (same as the CDP path) — never the whole jar.
|
|
263
|
+
*/
|
|
264
|
+
async injectCookies(url, cookieOpts) {
|
|
265
|
+
if (incognito) return 0;
|
|
266
|
+
const cookies = scopedCookiesForUrl(url, { browser: cookieOpts?.browser });
|
|
267
|
+
let injected = 0;
|
|
268
|
+
for (const c of cookies) {
|
|
269
|
+
const cookie = {
|
|
270
|
+
name: c.name,
|
|
271
|
+
value: { type: 'string', value: String(c.value ?? '') },
|
|
272
|
+
domain: String(c.domain || '').replace(/^\./, ''),
|
|
273
|
+
path: c.path || '/',
|
|
274
|
+
};
|
|
275
|
+
if (c.secure) cookie.secure = true;
|
|
276
|
+
if (c.httpOnly) cookie.httpOnly = true;
|
|
277
|
+
if (c.sameSite) cookie.sameSite = String(c.sameSite).toLowerCase();
|
|
278
|
+
if (c.expires && c.expires > 0) cookie.expiry = Math.floor(c.expires);
|
|
279
|
+
try { await bidi.send('storage.setCookie', { cookie }); injected++; } catch { /* skip bad cookie */ }
|
|
280
|
+
}
|
|
281
|
+
return injected;
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
async select(ref, value) {
|
|
285
|
+
const { context, sharedId } = await resolveRef(ref);
|
|
286
|
+
// Native <select>: set value + fire change. Custom dropdown: open then
|
|
287
|
+
// click the matching option. Mirrors interact.js's two strategies.
|
|
288
|
+
const handled = await bidi.send('script.callFunction', {
|
|
289
|
+
functionDeclaration: `function(v){
|
|
290
|
+
if (this.tagName === 'SELECT') {
|
|
291
|
+
const opt = Array.from(this.options).find(o => o.value === v || o.textContent.trim() === v);
|
|
292
|
+
if (opt) { this.value = opt.value; this.dispatchEvent(new Event('change',{bubbles:true})); return true; }
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
this.click();
|
|
296
|
+
return false;
|
|
297
|
+
}`,
|
|
298
|
+
arguments: [{ type: 'string', value }],
|
|
299
|
+
this: { sharedId }, target: { context }, awaitPromise: false,
|
|
300
|
+
});
|
|
301
|
+
if (handled.type === 'success' && handled.result?.value === true) return;
|
|
302
|
+
// Custom dropdown fallback: click a matching option after it opens.
|
|
303
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
304
|
+
await bidi.evaluate(context, `(() => {
|
|
305
|
+
for (const o of document.querySelectorAll('[role="option"],[role="menuitem"],li[role="option"]')) {
|
|
306
|
+
if (o.textContent.trim() === ${JSON.stringify(value)}) { o.click(); return true; }
|
|
307
|
+
}
|
|
308
|
+
return false;
|
|
309
|
+
})()`, false);
|
|
310
|
+
},
|
|
311
|
+
|
|
312
|
+
async drag(fromRef, toRef) {
|
|
313
|
+
const from = await resolveRef(fromRef);
|
|
314
|
+
const to = await resolveRef(toRef);
|
|
315
|
+
if (from.context !== to.context) {
|
|
316
|
+
throw new Error('drag() between elements in different frames is not supported');
|
|
317
|
+
}
|
|
318
|
+
await bidi.send('input.performActions', {
|
|
319
|
+
context: from.context,
|
|
320
|
+
actions: [{
|
|
321
|
+
type: 'pointer', id: 'mouse', parameters: { pointerType: 'mouse' },
|
|
322
|
+
actions: [
|
|
323
|
+
{ type: 'pointerMove', x: 0, y: 0, origin: { type: 'element', element: { sharedId: from.sharedId } } },
|
|
324
|
+
{ type: 'pointerDown', button: 0 },
|
|
325
|
+
{ type: 'pointerMove', x: 0, y: 0, origin: { type: 'element', element: { sharedId: to.sharedId } } },
|
|
326
|
+
{ type: 'pointerUp', button: 0 },
|
|
327
|
+
],
|
|
328
|
+
}],
|
|
329
|
+
});
|
|
330
|
+
},
|
|
331
|
+
|
|
332
|
+
async upload(ref, files) {
|
|
333
|
+
// Same upload sandbox the CDP path enforces: every path must resolve
|
|
334
|
+
// (symlinks included) inside uploadDir when set.
|
|
335
|
+
assertUploadAllowed(files, uploadDir);
|
|
336
|
+
const { context, sharedId } = await resolveRef(ref);
|
|
337
|
+
await bidi.send('input.setFiles', { context, element: { sharedId }, files });
|
|
338
|
+
},
|
|
339
|
+
|
|
340
|
+
async goBack() { await traverse(-1); },
|
|
341
|
+
async goForward() { await traverse(1); },
|
|
342
|
+
|
|
343
|
+
async reload() {
|
|
344
|
+
// Note: Firefox BiDi does not yet support the ignoreCache argument, so
|
|
345
|
+
// (unlike the CDP path) reload() always does a normal reload.
|
|
346
|
+
await withTimeout(
|
|
347
|
+
bidi.send('browsingContext.reload', { context: topContext, wait: 'complete' }),
|
|
348
|
+
30000, 'reload');
|
|
349
|
+
refContexts = new Map();
|
|
350
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
351
|
+
},
|
|
352
|
+
|
|
353
|
+
async screenshot(screenshotOpts = {}) {
|
|
354
|
+
const fmt = screenshotOpts.format || 'png';
|
|
355
|
+
const type = fmt === 'jpeg' ? 'image/jpeg' : fmt === 'webp' ? 'image/webp' : 'image/png';
|
|
356
|
+
const format = { type };
|
|
357
|
+
if (type !== 'image/png') format.quality = (screenshotOpts.quality || 80) / 100;
|
|
358
|
+
const { data } = await bidi.send('browsingContext.captureScreenshot', { context: topContext, format });
|
|
359
|
+
return data; // base64
|
|
360
|
+
},
|
|
361
|
+
|
|
362
|
+
async pdf(pdfOpts = {}) {
|
|
363
|
+
const { data } = await bidi.send('browsingContext.print', {
|
|
364
|
+
context: topContext,
|
|
365
|
+
background: true,
|
|
366
|
+
orientation: pdfOpts.landscape ? 'landscape' : 'portrait',
|
|
367
|
+
});
|
|
368
|
+
return data; // base64
|
|
369
|
+
},
|
|
370
|
+
|
|
371
|
+
async tabs() {
|
|
372
|
+
const { contexts: tree } = await bidi.send('browsingContext.getTree', {});
|
|
373
|
+
const out = [];
|
|
374
|
+
for (let i = 0; i < tree.length; i++) {
|
|
375
|
+
const title = await bidi.evaluate(tree[i].context, 'document.title', false).catch(() => '');
|
|
376
|
+
out.push({ index: i, url: tree[i].url, title, context: tree[i].context });
|
|
377
|
+
}
|
|
378
|
+
return out;
|
|
379
|
+
},
|
|
380
|
+
|
|
381
|
+
async switchTab(index) {
|
|
382
|
+
const { contexts: tree } = await bidi.send('browsingContext.getTree', {});
|
|
383
|
+
if (index < 0 || index >= tree.length) throw new Error(`Tab index ${index} out of range (0-${tree.length - 1})`);
|
|
384
|
+
topContext = tree[index].context;
|
|
385
|
+
refContexts = new Map(); // refs from the previous tab are invalid
|
|
386
|
+
await bidi.send('browsingContext.activate', { context: topContext }).catch(() => {});
|
|
387
|
+
},
|
|
388
|
+
|
|
389
|
+
async waitFor(waitOpts = {}) {
|
|
390
|
+
const timeout = waitOpts.timeout || 30000;
|
|
391
|
+
const deadline = Date.now() + timeout;
|
|
392
|
+
while (Date.now() < deadline) {
|
|
393
|
+
if (waitOpts.text) {
|
|
394
|
+
const t = await bidi.evaluate(topContext, 'document.body ? document.body.innerText : ""', false).catch(() => '');
|
|
395
|
+
if (t && t.includes(waitOpts.text)) return;
|
|
396
|
+
}
|
|
397
|
+
if (waitOpts.selector) {
|
|
398
|
+
const found = await bidi.evaluate(topContext, `!!document.querySelector(${JSON.stringify(waitOpts.selector)})`, false).catch(() => false);
|
|
399
|
+
if (found) return;
|
|
400
|
+
}
|
|
401
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
402
|
+
}
|
|
403
|
+
throw new Error(`waitFor timed out after ${timeout}ms`);
|
|
404
|
+
},
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Wait until the network has been idle for `idle` ms, over BiDi
|
|
408
|
+
* network.* events. Parity with the CDP page.waitForNetworkIdle (Phase 2).
|
|
409
|
+
*/
|
|
410
|
+
async waitForNetworkIdle(idleOpts = {}) {
|
|
411
|
+
return waitForNetworkIdleBiDi(bidi, idleOpts);
|
|
412
|
+
},
|
|
413
|
+
|
|
414
|
+
// --- CDP-only surfaces, stubbed for daemon parity ---------------------
|
|
415
|
+
// The daemon (src/daemon.js) dispatches these unconditionally. On the
|
|
416
|
+
// Firefox/BiDi engine download tracking and dialog capture aren't wired
|
|
417
|
+
// (Phase 3/4), so the two logs are genuinely empty; the two actions are
|
|
418
|
+
// CDP-only and fail with a clear, intentional message instead of an
|
|
419
|
+
// incidental TypeError. Documented as a known gap in CHANGELOG.
|
|
420
|
+
get downloads() { return []; },
|
|
421
|
+
get dialogLog() { return []; },
|
|
422
|
+
async saveState() {
|
|
423
|
+
throw new Error('saveState() is not supported on the Firefox/BiDi engine (CDP-only)');
|
|
424
|
+
},
|
|
425
|
+
async waitForNavigation() {
|
|
426
|
+
throw new Error('waitForNavigation() is not supported on the Firefox/BiDi engine (CDP-only)');
|
|
427
|
+
},
|
|
428
|
+
|
|
429
|
+
async close() {
|
|
430
|
+
try { await bidi.send('browsingContext.close', { context: topContext }); } catch {}
|
|
431
|
+
bidi.close();
|
|
432
|
+
},
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
/** Walk session history by delta and settle (BiDi has no load-wait here). */
|
|
436
|
+
async function traverse(delta) {
|
|
437
|
+
await withTimeout(
|
|
438
|
+
bidi.send('browsingContext.traverseHistory', { context: topContext, delta }),
|
|
439
|
+
30000, 'history navigation');
|
|
440
|
+
refContexts = new Map();
|
|
441
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return page;
|
|
445
|
+
}
|
package/src/firefox.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* firefox.js — Find and launch Firefox with WebDriver BiDi enabled.
|
|
3
|
+
*
|
|
4
|
+
* The BiDi counterpart to chromium.js. Firefox deprecated CDP, so it's driven
|
|
5
|
+
* over the W3C BiDi protocol instead. `--remote-debugging-port` starts
|
|
6
|
+
* Firefox's remote agent, which prints its BiDi endpoint to stderr:
|
|
7
|
+
* "WebDriver BiDi listening on ws://127.0.0.1:PORT"
|
|
8
|
+
* The direct-connection socket (no geckodriver / WebDriver-classic handshake)
|
|
9
|
+
* is that URL + "/session" — createBiDi() appends it. No new dependency:
|
|
10
|
+
* BiDi rides the same `ws` transport as CDP.
|
|
11
|
+
*
|
|
12
|
+
* Like chromium.js we launch a fresh temp profile (never the user's live
|
|
13
|
+
* profile — that would profile-lock their running Firefox) and reap the
|
|
14
|
+
* process + profile dir on parent crash.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
18
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
|
|
22
|
+
/** Block the current thread for `ms` (sync, for the 'exit' reaper). */
|
|
23
|
+
function sleepSync(ms) {
|
|
24
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Reap launched Firefoxes on parent crash — mirrors chromium.js.
|
|
28
|
+
const activeBrowsers = new Set();
|
|
29
|
+
let exitHandlersRegistered = false;
|
|
30
|
+
|
|
31
|
+
function reapAllSync() {
|
|
32
|
+
const toReap = [...activeBrowsers];
|
|
33
|
+
activeBrowsers.clear();
|
|
34
|
+
for (const b of toReap) {
|
|
35
|
+
try { if (!b.process.killed) b.process.kill('SIGKILL'); } catch {}
|
|
36
|
+
try { process.kill(-b.process.pid, 'SIGKILL'); } catch {}
|
|
37
|
+
}
|
|
38
|
+
for (const b of toReap) {
|
|
39
|
+
for (let i = 0; i < 20; i++) {
|
|
40
|
+
try { process.kill(b.process.pid, 0); } catch { break; }
|
|
41
|
+
sleepSync(50);
|
|
42
|
+
}
|
|
43
|
+
if (b.ownedProfileDir) {
|
|
44
|
+
try { rmSync(b.ownedProfileDir, { recursive: true, force: true }); } catch {}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function registerExitHandlers() {
|
|
50
|
+
if (exitHandlersRegistered) return;
|
|
51
|
+
exitHandlersRegistered = true;
|
|
52
|
+
process.once('exit', reapAllSync);
|
|
53
|
+
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
54
|
+
process.once(sig, () => { reapAllSync(); process.kill(process.pid, sig); });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const CANDIDATES = [
|
|
59
|
+
'firefox',
|
|
60
|
+
'firefox-esr',
|
|
61
|
+
'firefox-developer-edition',
|
|
62
|
+
'librewolf',
|
|
63
|
+
'/Applications/Firefox.app/Contents/MacOS/firefox',
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Find the first available Firefox binary on the system.
|
|
68
|
+
* @returns {string} Path to the binary
|
|
69
|
+
* @throws {Error} If no Firefox is found
|
|
70
|
+
*/
|
|
71
|
+
export function findFirefox() {
|
|
72
|
+
for (const candidate of CANDIDATES) {
|
|
73
|
+
try {
|
|
74
|
+
if (candidate.startsWith('/')) {
|
|
75
|
+
execFileSync('test', ['-f', candidate]);
|
|
76
|
+
return candidate;
|
|
77
|
+
}
|
|
78
|
+
const path = execFileSync('which', [candidate], {
|
|
79
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
80
|
+
}).trim();
|
|
81
|
+
if (path) return path;
|
|
82
|
+
} catch { /* try next */ }
|
|
83
|
+
}
|
|
84
|
+
throw new Error(
|
|
85
|
+
'No Firefox binary found. Install Firefox (>= 121 for stable BiDi).\n' +
|
|
86
|
+
'On Fedora: sudo dnf install firefox'
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Launch Firefox with WebDriver BiDi enabled and return its session WS URL.
|
|
92
|
+
*
|
|
93
|
+
* @param {object} [opts]
|
|
94
|
+
* @param {string} [opts.binary] - Firefox binary (auto-detected if omitted)
|
|
95
|
+
* @param {number} [opts.port=0] - Remote-agent port (0 = OS-assigned)
|
|
96
|
+
* @param {boolean} [opts.headed=false] - Launch with a visible window
|
|
97
|
+
* @param {string} [opts.proxy] - Proxy 'host:port' or 'scheme://host:port'
|
|
98
|
+
* (http/https → HTTP+SSL proxy; socks/socks5/socks4 → SOCKS), via prefs
|
|
99
|
+
* @param {{width:number,height:number}} [opts.viewport] - Initial window size
|
|
100
|
+
* @returns {Promise<{wsUrl: string, process: import('node:child_process').ChildProcess, port: number, ownedProfileDir: string}>}
|
|
101
|
+
*/
|
|
102
|
+
export async function launchFirefox(opts = {}) {
|
|
103
|
+
const binary = opts.binary || findFirefox();
|
|
104
|
+
const port = opts.port || 0;
|
|
105
|
+
const profileDir = mkdtempSync(join(tmpdir(), 'barebrowse-ff-'));
|
|
106
|
+
|
|
107
|
+
// Proxy + prompt-suppressing prefs go in user.js (read at profile load).
|
|
108
|
+
const prefs = [
|
|
109
|
+
'user_pref("browser.shell.checkDefaultBrowser", false);',
|
|
110
|
+
'user_pref("datareporting.policy.dataSubmissionEnabled", false);',
|
|
111
|
+
'user_pref("dom.webnotifications.enabled", false);',
|
|
112
|
+
'user_pref("permissions.default.desktop-notification", 1);',
|
|
113
|
+
'user_pref("media.navigator.permission.disabled", true);',
|
|
114
|
+
'user_pref("geo.prompt.testing", true);',
|
|
115
|
+
'user_pref("geo.prompt.testing.allow", true);',
|
|
116
|
+
];
|
|
117
|
+
if (opts.proxy) {
|
|
118
|
+
// Honor the scheme: a socks:// proxy must be wired as SOCKS, not HTTP —
|
|
119
|
+
// otherwise SOCKS traffic is silently sent to an HTTP proxy and fails.
|
|
120
|
+
const raw = String(opts.proxy);
|
|
121
|
+
const scheme = (raw.match(/^(\w+):\/\//)?.[1] || '').toLowerCase();
|
|
122
|
+
const [host, pport] = raw.replace(/^\w+:\/\//, '').split(':');
|
|
123
|
+
const isSocks = scheme.startsWith('socks');
|
|
124
|
+
const port = Number(pport) || (isSocks ? 1080 : 8080);
|
|
125
|
+
prefs.push('user_pref("network.proxy.type", 1);');
|
|
126
|
+
if (isSocks) {
|
|
127
|
+
prefs.push(
|
|
128
|
+
`user_pref("network.proxy.socks", "${host}");`,
|
|
129
|
+
`user_pref("network.proxy.socks_port", ${port});`,
|
|
130
|
+
`user_pref("network.proxy.socks_version", ${scheme === 'socks4' ? 4 : 5});`,
|
|
131
|
+
'user_pref("network.proxy.socks_remote_dns", true);',
|
|
132
|
+
);
|
|
133
|
+
} else {
|
|
134
|
+
prefs.push(
|
|
135
|
+
`user_pref("network.proxy.http", "${host}");`,
|
|
136
|
+
`user_pref("network.proxy.http_port", ${port});`,
|
|
137
|
+
`user_pref("network.proxy.ssl", "${host}");`,
|
|
138
|
+
`user_pref("network.proxy.ssl_port", ${port});`,
|
|
139
|
+
'user_pref("network.proxy.share_proxy_settings", true);',
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
writeFileSync(join(profileDir, 'user.js'), prefs.join('\n'));
|
|
144
|
+
|
|
145
|
+
const args = [
|
|
146
|
+
'--remote-debugging-port', String(port),
|
|
147
|
+
'--no-remote', '--new-instance',
|
|
148
|
+
'--profile', profileDir,
|
|
149
|
+
];
|
|
150
|
+
if (!opts.headed) args.push('--headless');
|
|
151
|
+
if (opts.viewport) args.push('--width', String(opts.viewport.width), '--height', String(opts.viewport.height));
|
|
152
|
+
args.push('about:blank');
|
|
153
|
+
|
|
154
|
+
// detached:true → own process group, so cleanup can signal the whole tree.
|
|
155
|
+
const child = spawn(binary, args, { stdio: ['ignore', 'pipe', 'pipe'], detached: true });
|
|
156
|
+
|
|
157
|
+
// Firefox prints the BiDi endpoint to stderr. Wait for it (or die trying).
|
|
158
|
+
const wsUrl = await new Promise((resolve, reject) => {
|
|
159
|
+
let buf = '';
|
|
160
|
+
const timeout = setTimeout(() => reject(new Error(`Firefox BiDi did not start within 20s. stderr: ${buf}`)), 20000);
|
|
161
|
+
const scan = (chunk) => {
|
|
162
|
+
buf += chunk.toString();
|
|
163
|
+
const m = buf.match(/WebDriver BiDi listening on (ws:\/\/\S+)/);
|
|
164
|
+
if (m) { clearTimeout(timeout); resolve(m[1].replace(/\/?$/, '') + '/session'); }
|
|
165
|
+
};
|
|
166
|
+
child.stderr.on('data', scan);
|
|
167
|
+
child.stdout.on('data', scan);
|
|
168
|
+
child.on('error', (err) => { clearTimeout(timeout); reject(new Error(`Failed to launch Firefox: ${err.message}`)); });
|
|
169
|
+
child.on('exit', (code) => {
|
|
170
|
+
clearTimeout(timeout);
|
|
171
|
+
if (!buf.includes('WebDriver BiDi')) reject(new Error(`Firefox exited with code ${code}. stderr: ${buf}`));
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const actualPort = parseInt(new URL(wsUrl.replace('/session', '')).port, 10);
|
|
176
|
+
const browser = { wsUrl, process: child, port: actualPort, ownedProfileDir: profileDir };
|
|
177
|
+
|
|
178
|
+
registerExitHandlers();
|
|
179
|
+
activeBrowsers.add(browser);
|
|
180
|
+
child.once('exit', () => activeBrowsers.delete(browser));
|
|
181
|
+
|
|
182
|
+
return browser;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Kill a launched Firefox and remove its temp profile dir.
|
|
187
|
+
* @param {{process: import('node:child_process').ChildProcess, ownedProfileDir?: string}} browser
|
|
188
|
+
*/
|
|
189
|
+
export async function cleanupFirefox(browser) {
|
|
190
|
+
if (!browser) return;
|
|
191
|
+
activeBrowsers.delete(browser);
|
|
192
|
+
const pid = browser.process.pid;
|
|
193
|
+
try { if (!browser.process.killed) browser.process.kill('SIGKILL'); } catch {}
|
|
194
|
+
if (pid != null) try { process.kill(-pid, 'SIGKILL'); } catch {}
|
|
195
|
+
// Wait for death so rmSync doesn't race Firefox's profile file handles.
|
|
196
|
+
for (let i = 0; pid != null && i < 40; i++) {
|
|
197
|
+
try { process.kill(pid, 0); } catch { break; }
|
|
198
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
199
|
+
}
|
|
200
|
+
if (browser.ownedProfileDir) {
|
|
201
|
+
try { rmSync(browser.ownedProfileDir, { recursive: true, force: true }); } catch {}
|
|
202
|
+
}
|
|
203
|
+
}
|