barebrowse 0.15.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/src/daemon.js CHANGED
@@ -67,6 +67,74 @@ export function buildDaemonArgs(opts, absDir, initialUrl, cliPath) {
67
67
  return args;
68
68
  }
69
69
 
70
+ /**
71
+ * Wire Firefox/BiDi console + network capture onto the daemon's log arrays.
72
+ * The BiDi analogue of the CDP `Runtime.consoleAPICalled` / `Network.*`
73
+ * capture — measured event shapes: `log.entryAdded` carries {method, level,
74
+ * args:[{type,value}], text}; the network events key in-flight requests by
75
+ * `request.request` and expose `response.{status,statusText,mimeType}` /
76
+ * top-level `errorText`. Pure + exported so the shape mapping (warn→warning
77
+ * normalization, arg extraction, orphan-safe response/error pairing) is
78
+ * unit-testable against a fake bidi without launching Firefox.
79
+ *
80
+ * @param {object} bidi - BiDi client (async .subscribe(), .on(method, cb))
81
+ * @param {object} sinks
82
+ * @param {Array} sinks.consoleLogs - push-target for console entries
83
+ * @param {Array} sinks.networkLogs - push-target for completed/failed requests
84
+ * @param {Map} sinks.pendingRequests - in-flight request bookkeeping
85
+ * @returns {Promise<void>}
86
+ */
87
+ export async function attachBiDiCapture(bidi, { consoleLogs, networkLogs, pendingRequests }) {
88
+ await bidi.subscribe([
89
+ 'log.entryAdded',
90
+ 'network.beforeRequestSent',
91
+ 'network.responseCompleted',
92
+ 'network.fetchError',
93
+ ]);
94
+
95
+ bidi.on('log.entryAdded', (e) => {
96
+ consoleLogs.push({
97
+ // Map BiDi's console method (or level for uncaught JS errors) to CDP's
98
+ // `type` vocabulary so `console-logs --level warning` matches on both
99
+ // engines — BiDi says 'warn', CDP says 'warning'.
100
+ type: e.method === 'warn' ? 'warning' : (e.method || e.level),
101
+ timestamp: new Date().toISOString(),
102
+ args: Array.isArray(e.args) && e.args.length
103
+ ? e.args.map((a) => a.value ?? a.type)
104
+ : [e.text],
105
+ });
106
+ });
107
+
108
+ bidi.on('network.beforeRequestSent', (e) => {
109
+ pendingRequests.set(e.request.request, {
110
+ url: e.request.url,
111
+ method: e.request.method,
112
+ timestamp: new Date().toISOString(),
113
+ });
114
+ });
115
+
116
+ bidi.on('network.responseCompleted', (e) => {
117
+ const req = pendingRequests.get(e.request.request);
118
+ if (req) {
119
+ networkLogs.push({
120
+ ...req,
121
+ status: e.response.status,
122
+ statusText: e.response.statusText,
123
+ mimeType: e.response.mimeType,
124
+ });
125
+ pendingRequests.delete(e.request.request);
126
+ }
127
+ });
128
+
129
+ bidi.on('network.fetchError', (e) => {
130
+ const req = pendingRequests.get(e.request.request);
131
+ if (req) {
132
+ networkLogs.push({ ...req, status: 0, error: e.errorText });
133
+ pendingRequests.delete(e.request.request);
134
+ }
135
+ });
136
+ }
137
+
70
138
  /**
71
139
  * Spawn a detached child process that runs the daemon.
72
140
  * Parent polls for session.json, then exits.
@@ -139,10 +207,10 @@ export async function runDaemon(opts, outputDir, initialUrl) {
139
207
  incognito: opts.incognito || opts.cookies === false,
140
208
  });
141
209
 
142
- // Console + network log capture is CDP-specific (page.cdp). The Firefox/BiDi
143
- // engine exposes page.bidi instead, so these captures are skipped there
144
- // console-logs / network-log commands return empty for a Firefox session.
145
- // (BiDi log.entryAdded / network.* events could back these later.)
210
+ // Console + network log capture. CDP uses Runtime/Network events off
211
+ // page.cdp; the Firefox/BiDi engine (page.bidi) captures the same shape over
212
+ // log.entryAdded / network.* events (Phase 2 attachBiDiCapture). Either
213
+ // way console-logs / network-log / wait-idle behave the same for both.
146
214
  const consoleLogs = [];
147
215
  const networkLogs = [];
148
216
  const pendingRequests = new Map();
@@ -190,6 +258,8 @@ export async function runDaemon(opts, outputDir, initialUrl) {
190
258
  pendingRequests.delete(params.requestId);
191
259
  }
192
260
  });
261
+ } else if (page.bidi) {
262
+ await attachBiDiCapture(page.bidi, { consoleLogs, networkLogs, pendingRequests });
193
263
  }
194
264
 
195
265
  // Navigate to initial URL if provided
@@ -21,6 +21,8 @@ import { axSnapshotExpression, REF_ATTR } from './ax-snapshot.js';
21
21
  import { EXTRACT_EXPRESSION, finalizeReadable } from './readable.js';
22
22
  import { scopedCookiesForUrl } from './auth.js';
23
23
  import { assertNavigable, assertUploadAllowed } from './url-guard.js';
24
+ import { dismissConsentFirefox } from './consent-firefox.js';
25
+ import { waitForNetworkIdleBiDi } from './network-idle.js';
24
26
 
25
27
  /** BiDi/WebDriver normalized key values for named keys (U+E000 block). */
26
28
  const BIDI_KEYS = {
@@ -38,6 +40,7 @@ const BIDI_KEYS = {
38
40
  * @param {{allowLocalUrls?: boolean, blockPrivateNetwork?: boolean}} [opts.urlGuard] - Navigation safety policy, applied on every goto().
39
41
  * @param {string} [opts.uploadDir] - When set, upload() rejects files outside this dir.
40
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().
41
44
  * @returns {Promise<object>} page object
42
45
  */
43
46
  export async function createFirefoxPage(bidi, opts = {}) {
@@ -45,6 +48,7 @@ export async function createFirefoxPage(bidi, opts = {}) {
45
48
  const urlGuard = opts.urlGuard || {};
46
49
  const uploadDir = opts.uploadDir || null;
47
50
  const incognito = !!opts.incognito;
51
+ const consent = opts.consent !== false;
48
52
  // The active browsing context. Starts at the initial tab; switchTab() points
49
53
  // it at another top-level context, so it's mutable and read via a getter.
50
54
  const { contexts } = await bidi.send('browsingContext.getTree', {});
@@ -71,6 +75,53 @@ export async function createFirefoxPage(bidi, opts = {}) {
71
75
  return flat;
72
76
  }
73
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
+
74
125
  /** Resolve a ref to a BiDi element sharedId in its owning context. */
75
126
  async function resolveRef(ref) {
76
127
  const context = refContexts.get(String(ref));
@@ -134,46 +185,19 @@ export async function createFirefoxPage(bidi, opts = {}) {
134
185
  timeout, `goto(${url})`);
135
186
  // Brief settle for dynamic/SPA content, matching the CDP path.
136
187
  await new Promise((r) => setTimeout(r, 500));
137
- },
138
-
139
- async snapshot(pruneOpts) {
140
- const contextIds = await allContexts();
141
- refContexts = new Map();
142
-
143
- // Snapshot each context, assigning refs from a shared running counter so
144
- // they're globally unique (matching CDP's flat integer refs).
145
- let base = 0;
146
- const treesByContext = new Map();
147
- for (const ctx of contextIds) {
148
- let raw;
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) {
149
192
  try {
150
- raw = await bidi.evaluate(ctx, axSnapshotExpression(base), false);
151
- } catch { continue; } // frame navigated mid-snapshot — skip it
152
- const { tree, count } = JSON.parse(raw);
153
- for (let r = base + 1; r <= base + count; r++) refContexts.set(String(r), ctx);
154
- base += count;
155
- treesByContext.set(ctx, tree);
156
- }
157
-
158
- // Splice each child frame's tree under an <iframe> (role 'Iframe')
159
- // placeholder in its parent, matched by document order — BiDi getTree
160
- // lists children in frame order, and the AX walk emits Iframe nodes in
161
- // the same order.
162
- const iframeNodes = (tree) => {
163
- const out = [];
164
- (function walk(n) { if (n.role === 'Iframe') out.push(n); for (const c of n.children) walk(c); })(tree);
165
- return out;
166
- };
167
- for (let i = 1; i < contextIds.length; i++) {
168
- const childTree = treesByContext.get(contextIds[i]);
169
- if (!childTree) continue;
170
- // Attach to the next unfilled Iframe placeholder in the top tree.
171
- const holders = iframeNodes(treesByContext.get(topContext) || { role: '', children: [] });
172
- const holder = holders[i - 1];
173
- if (holder) holder.children = [childTree];
193
+ const root = await buildTree();
194
+ if (root) await dismissConsentFirefox(root, (ref) => pointerClick(ref));
195
+ } catch { /* consent dismissal is best-effort */ }
174
196
  }
197
+ },
175
198
 
176
- const root = treesByContext.get(topContext);
199
+ async snapshot(pruneOpts) {
200
+ const root = await buildTree();
177
201
  if (!root) return '';
178
202
 
179
203
  const pageUrl = await bidi.evaluate(topContext, 'location.href', false).catch(() => '');
@@ -379,12 +403,20 @@ export async function createFirefoxPage(bidi, opts = {}) {
379
403
  throw new Error(`waitFor timed out after ${timeout}ms`);
380
404
  },
381
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
+
382
414
  // --- CDP-only surfaces, stubbed for daemon parity ---------------------
383
415
  // The daemon (src/daemon.js) dispatches these unconditionally. On the
384
- // Firefox/BiDi engine download tracking and dialog capture aren't wired,
385
- // so the two logs are genuinely empty; the three actions are CDP-only and
386
- // fail with a clear, intentional message instead of an incidental
387
- // TypeError. Documented as a known gap in CHANGELOG (Firefox engine).
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.
388
420
  get downloads() { return []; },
389
421
  get dialogLog() { return []; },
390
422
  async saveState() {
@@ -393,9 +425,6 @@ export async function createFirefoxPage(bidi, opts = {}) {
393
425
  async waitForNavigation() {
394
426
  throw new Error('waitForNavigation() is not supported on the Firefox/BiDi engine (CDP-only)');
395
427
  },
396
- async waitForNetworkIdle() {
397
- throw new Error('waitForNetworkIdle() is not supported on the Firefox/BiDi engine (CDP-only)');
398
- },
399
428
 
400
429
  async close() {
401
430
  try { await bidi.send('browsingContext.close', { context: topContext }); } catch {}
package/src/index.js CHANGED
@@ -19,6 +19,7 @@ import { prune as pruneTree } from './prune.js';
19
19
  import { click as cdpClick, type as cdpType, scroll as cdpScroll, press as cdpPress, hover as cdpHover, select as cdpSelect, drag as cdpDrag, upload as cdpUpload } from './interact.js';
20
20
  import { dismissConsent } from './consent.js';
21
21
  import { applyStealth } from './stealth.js';
22
+ import { applyFirefoxStealth } from './stealth-firefox.js';
22
23
  import { DEFAULT_BLOCKLIST } from './blocklist.js';
23
24
  import { waitForNetworkIdle } from './network-idle.js';
24
25
  import { readable as extractReadable } from './readable.js';
@@ -746,12 +747,17 @@ async function suppressPermissions(cdp) {
746
747
  * ref model, and AX-tree source all differ (see firefox-page.js). close() is
747
748
  * wrapped to also reap the Firefox process + temp profile.
748
749
  *
749
- * Chromium-only options NOT applied on this path: `consent` (auto-dismiss),
750
- * `blockAds`/`blockUrls`, stealth patches, and `hybrid` mode. `saveState`,
751
- * `waitForNavigation`, `waitForNetworkIdle`, and download/dialog capture are
752
- * CDP-only too (the page object stubs them see firefox-page.js). The
753
- * navigation guard (`allowLocalUrls`/`blockPrivateNetwork`), `uploadDir`
754
- * sandbox, `incognito`, `proxy`, `viewport`, and `pruneMode` DO apply.
750
+ * Chromium-only options NOT applied on this path: `blockAds`/`blockUrls` and
751
+ * `hybrid` mode. `saveState`, `waitForNavigation`, and download/dialog capture
752
+ * are CDP-only too (the page object stubs them — see firefox-page.js).
753
+ * `waitForNetworkIdle` and daemon console/network capture DO apply as of Phase
754
+ * 2 (over BiDi `network.*` and `log.entryAdded` events). `consent`
755
+ * (auto-dismiss) and
756
+ * stealth patches DO apply here as of v0.16.0 (stealth headless-only, via BiDi
757
+ * preload script — see stealth-firefox.js / consent-firefox.js), alongside the
758
+ * navigation guard
759
+ * (`allowLocalUrls`/`blockPrivateNetwork`), `uploadDir` sandbox, `incognito`,
760
+ * `proxy`, `viewport`, and `pruneMode`.
755
761
  * @param {object} opts - connect() options ({ mode, proxy, binary, viewport, pruneMode, urlGuard, uploadDir, incognito })
756
762
  * @returns {Promise<object>} Firefox page object
757
763
  */
@@ -763,11 +769,18 @@ async function connectFirefox(opts) {
763
769
  viewport: opts.viewport,
764
770
  });
765
771
  const bidi = await createBiDi(browser.wsUrl);
772
+ // Stealth patches (headless only, mirroring the CDP path's `mode !== 'headed'`
773
+ // gate). Registered before the first navigation via script.addPreloadScript.
774
+ // Firefox needs a much smaller script than Chromium — see stealth-firefox.js.
775
+ if (opts.mode !== 'headed') {
776
+ await applyFirefoxStealth(bidi);
777
+ }
766
778
  const page = await createFirefoxPage(bidi, {
767
779
  pruneMode: opts.pruneMode,
768
780
  urlGuard: { allowLocalUrls: opts.allowLocalUrls, blockPrivateNetwork: opts.blockPrivateNetwork },
769
781
  uploadDir: opts.uploadDir || null,
770
782
  incognito: !!opts.incognito,
783
+ consent: opts.consent,
771
784
  });
772
785
  const closePage = page.close.bind(page);
773
786
  page.close = async () => {
@@ -2,24 +2,27 @@
2
2
  * network-idle.js — wait until the page's network has been idle for N ms.
3
3
  *
4
4
  * Tracks in-flight requests by requestId in a Set, so an orphan
5
- * loadingFinished/Failed (event for a request whose requestWillBeSent
6
- * arrived before our listener attached) is a harmless no-op instead of
7
- * driving a counter negative and resolving prematurely.
5
+ * finish/fail event (for a request whose start event arrived before our
6
+ * listener attached) is a harmless no-op instead of driving a counter
7
+ * negative and resolving prematurely.
8
+ *
9
+ * Two engines, one core: `waitForNetworkIdle` wires the CDP `Network.*`
10
+ * events, `waitForNetworkIdleBiDi` wires the WebDriver BiDi `network.*`
11
+ * events. Both feed the same orphan-resilient idle waiter (`idleWaiter`).
8
12
  */
9
13
 
10
14
  /**
11
- * @param {object} session - CDP session-scoped handle with .on() returning unsub
12
- * @param {object} [opts]
13
- * @param {number} [opts.timeout=30000] - Max wait time before reject
14
- * @param {number} [opts.idle=500] - Required idle duration before resolve
15
+ * Shared idle-detection loop. `attach` registers the engine-specific event
16
+ * listeners, calling onStart(id) when a request begins and onEnd(id) when it
17
+ * finishes/fails, and pushing each listener's unsub fn into `unsubs`.
18
+ * @param {object} cfg
19
+ * @param {number} cfg.timeout - Max wait before reject
20
+ * @param {number} cfg.idle - Required quiet duration before resolve
21
+ * @param {(hooks: {onStart: (id:any)=>void, onEnd: (id:any)=>void, unsubs: Array<()=>void>}) => void} cfg.attach
15
22
  * @returns {Promise<void>}
16
23
  */
17
- export function waitForNetworkIdle(session, opts = {}) {
18
- const timeout = opts.timeout || 30000;
19
- const idle = opts.idle || 500;
20
-
21
- /** @type {Promise<void>} */
22
- const settled = new Promise((resolve, reject) => {
24
+ function idleWaiter({ timeout, idle, attach }) {
25
+ return new Promise((resolve, reject) => {
23
26
  const pending = new Set();
24
27
  let timer = null;
25
28
  const unsubs = [];
@@ -38,20 +41,12 @@ export function waitForNetworkIdle(session, opts = {}) {
38
41
  }
39
42
  };
40
43
 
41
- unsubs.push(session.on('Network.requestWillBeSent', (p) => {
42
- pending.add(p.requestId);
43
- clearTimeout(timer);
44
- }));
45
- unsubs.push(session.on('Network.loadingFinished', (p) => {
46
- // delete() on a Set is a no-op for unknown keys — orphan events from
47
- // requests started before we attached the listener can't push us negative.
48
- pending.delete(p.requestId);
49
- check();
50
- }));
51
- unsubs.push(session.on('Network.loadingFailed', (p) => {
52
- pending.delete(p.requestId);
53
- check();
54
- }));
44
+ const onStart = (id) => { pending.add(id); clearTimeout(timer); };
45
+ // delete() on a Set is a no-op for unknown keys — orphan finish/fail
46
+ // events from requests started before we attached can't push us negative.
47
+ const onEnd = (id) => { pending.delete(id); check(); };
48
+
49
+ attach({ onStart, onEnd, unsubs });
55
50
 
56
51
  const deadlineTimer = setTimeout(() => {
57
52
  for (const unsub of unsubs) unsub();
@@ -61,5 +56,48 @@ export function waitForNetworkIdle(session, opts = {}) {
61
56
  // Start check immediately (might already be idle)
62
57
  check();
63
58
  });
64
- return settled;
59
+ }
60
+
61
+ /**
62
+ * CDP: wait for network idle over `Network.*` events.
63
+ * @param {object} session - CDP session-scoped handle with .on() returning unsub
64
+ * @param {object} [opts]
65
+ * @param {number} [opts.timeout=30000] - Max wait time before reject
66
+ * @param {number} [opts.idle=500] - Required idle duration before resolve
67
+ * @returns {Promise<void>}
68
+ */
69
+ export function waitForNetworkIdle(session, opts = {}) {
70
+ return idleWaiter({
71
+ timeout: opts.timeout || 30000,
72
+ idle: opts.idle || 500,
73
+ attach: ({ onStart, onEnd, unsubs }) => {
74
+ unsubs.push(session.on('Network.requestWillBeSent', (p) => onStart(p.requestId)));
75
+ unsubs.push(session.on('Network.loadingFinished', (p) => onEnd(p.requestId)));
76
+ unsubs.push(session.on('Network.loadingFailed', (p) => onEnd(p.requestId)));
77
+ },
78
+ });
79
+ }
80
+
81
+ /**
82
+ * Firefox/BiDi: wait for network idle over `network.*` events. Subscribes the
83
+ * events first (idempotent — safe even if the daemon already subscribed for
84
+ * log capture), then runs the same orphan-resilient idle loop. The in-flight
85
+ * key is `params.request.request` (the BiDi request id).
86
+ * @param {object} bidi - BiDi client with async .subscribe() and .on() returning unsub
87
+ * @param {object} [opts]
88
+ * @param {number} [opts.timeout=30000] - Max wait time before reject
89
+ * @param {number} [opts.idle=500] - Required idle duration before resolve
90
+ * @returns {Promise<void>}
91
+ */
92
+ export async function waitForNetworkIdleBiDi(bidi, opts = {}) {
93
+ await bidi.subscribe(['network.beforeRequestSent', 'network.responseCompleted', 'network.fetchError']);
94
+ return idleWaiter({
95
+ timeout: opts.timeout || 30000,
96
+ idle: opts.idle || 500,
97
+ attach: ({ onStart, onEnd, unsubs }) => {
98
+ unsubs.push(bidi.on('network.beforeRequestSent', (p) => onStart(p.request.request)));
99
+ unsubs.push(bidi.on('network.responseCompleted', (p) => onEnd(p.request.request)));
100
+ unsubs.push(bidi.on('network.fetchError', (p) => onEnd(p.request.request)));
101
+ },
102
+ });
65
103
  }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * stealth-firefox.js — Anti-detection patches for headless Firefox (BiDi).
3
+ *
4
+ * Deliberately MUCH smaller than the Chromium stealth.js. That isn't laziness —
5
+ * it's what a POC measured stock Firefox-under-BiDi to actually expose:
6
+ *
7
+ * navigator.webdriver → true (the tell — we hide it)
8
+ * window.chrome → absent (correct; ADDING it would be a spoof tell)
9
+ * navigator.plugins → 5 (realistic PDF set; already normal)
10
+ * languages / hardwareConcurrency → normal (en-US,en / 8)
11
+ * User-Agent → real Firefox, NO "Headless" marker (no rewrite needed)
12
+ * WebGL vendor/renderer → the REAL GPU, not SwiftShader (spoofing would
13
+ * replace a real value with a fake one)
14
+ *
15
+ * So porting Chromium's STEALTH_SCRIPT verbatim would have made Firefox look
16
+ * like a spoofed browser (window.chrome + Chrome plugins on Firefox = obvious
17
+ * tell) — a worse fingerprint than the one removed. The only genuinely
18
+ * engine-agnostic pieces are webdriver-hiding and canvas noise, both reused
19
+ * verbatim from stealth.js so their fixes stay single-sourced.
20
+ *
21
+ * BiDi's script.addPreloadScript is the equivalent of CDP's
22
+ * Page.addScriptToEvaluateOnNewDocument — the POC confirmed it runs BEFORE any
23
+ * page script (navigator.webdriver read at parse time already saw `undefined`).
24
+ */
25
+
26
+ import { WEBDRIVER_PATCH, CANVAS_NOISE_PATCH } from './stealth.js';
27
+
28
+ const FIREFOX_STEALTH_SCRIPT = `${WEBDRIVER_PATCH}\n${CANVAS_NOISE_PATCH}`;
29
+
30
+ /**
31
+ * Register the Firefox stealth patches so they run before any page script.
32
+ * Must be called before the first navigation. addPreloadScript is global
33
+ * (applies to every browsing context / navigation), so a single registration
34
+ * covers the whole session.
35
+ *
36
+ * @param {object} bidi - BiDi client from createBiDi()
37
+ */
38
+ export async function applyFirefoxStealth(bidi) {
39
+ await bidi.send('script.addPreloadScript', {
40
+ functionDeclaration: `() => { ${FIREFOX_STEALTH_SCRIPT} }`,
41
+ });
42
+ }