barebrowse 0.15.0 → 0.18.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.
@@ -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
+ }
package/src/stealth.js CHANGED
@@ -5,9 +5,114 @@
5
5
  * any page scripts (unlike Runtime.evaluate which runs after).
6
6
  */
7
7
 
8
+ /**
9
+ * navigator.webdriver hiding — the one automation tell shared by every engine
10
+ * (Chromium headless AND Firefox-under-BiDi both report `true`). Split out so
11
+ * the Firefox stealth path (stealth-firefox.js) reuses the exact same patch
12
+ * instead of duplicating it. Measured baseline on both engines: `true`.
13
+ *
14
+ * A naive `Object.defineProperty(navigator, 'webdriver', …)` hides the *value*
15
+ * but leaves an advanced tell: it creates an OWN property on the instance, so
16
+ * `navigator.hasOwnProperty('webdriver')` becomes `true` where a real browser
17
+ * (property lives on Navigator.prototype) reports `false` — sophisticated
18
+ * anti-bot checks (e.g. sannysoft's "WebDriver New") catch that inconsistency.
19
+ * So we DELETE the getter off the prototype instead: then `navigator.webdriver`
20
+ * is `undefined` AND `'webdriver' in navigator` AND `hasOwnProperty` are both
21
+ * `false`, matching a stock browser. POC-verified on Chromium and Firefox
22
+ * (own/in both false, prototype descriptor gone). The defineProperty fallback
23
+ * only fires if the descriptor is somehow non-configurable (delete a no-op).
24
+ */
25
+ export const WEBDRIVER_PATCH = `
26
+ try {
27
+ const proto = Object.getPrototypeOf(navigator);
28
+ const desc = proto && Object.getOwnPropertyDescriptor(proto, 'webdriver');
29
+ if (desc && desc.configurable) delete proto.webdriver;
30
+ if (navigator.webdriver) {
31
+ Object.defineProperty(navigator, 'webdriver', { get: () => undefined, configurable: true });
32
+ }
33
+ } catch {}
34
+ `;
35
+
36
+ /**
37
+ * Canvas-fingerprint noise — browser-agnostic (standard Canvas2D API), so both
38
+ * the Chromium and Firefox stealth scripts compose it. This block carries the
39
+ * subtle double-XOR-cancellation fixes (see git history / project memory); keep
40
+ * it SINGLE-SOURCED here so a fix never has to be made in two places.
41
+ */
42
+ export const CANVAS_NOISE_PATCH = `
43
+ // Canvas fingerprinting — sites render standard text/shapes, then read
44
+ // pixels via toDataURL or getImageData. The output is stable per machine
45
+ // (GPU, font rasterizer, anti-aliasing) but unique across machines, which
46
+ // makes it the second-most-common fingerprint after WebGL. Defense: nudge
47
+ // a few RGB channels by ±1 per session so the hash changes each visit
48
+ // while the canvas still looks identical to the human eye. The per-tab
49
+ // seed keeps reads stable within a session so legitimate canvas use
50
+ // (image processing, games) doesn't flicker.
51
+ // crypto.getRandomValues is guaranteed unique per browsing context; using
52
+ // Math.random alone can collide when two fresh V8 contexts start within
53
+ // microseconds of each other (real-world: parallel tests, real-world hit:
54
+ // we observed it). performance.now adds a wall-clock anchor as a belt-and-
55
+ // braces guard against contexts where crypto is somehow stubbed.
56
+ const _seedBuf = new Uint32Array(1);
57
+ crypto.getRandomValues(_seedBuf);
58
+ const CANVAS_SEED = (_seedBuf[0] ^ ((performance.now() * 1e6) | 0)) >>> 0;
59
+ function shiftPixels(data) {
60
+ // Touch ~1 byte per 64-byte stride. The bit we XOR with is taken from a
61
+ // position-dependent SLICE of a seed-mixed hash, not its low bit — a
62
+ // naive 'mix & 1' collapses to only two possible outputs per seed
63
+ // parity because every stride index is even (the position multiplier
64
+ // is odd, so the low bit only depends on seed parity). Indexing the
65
+ // hash by (i/64) mod 32 makes every stride position sample a different
66
+ // bit, so two distinct seeds produce different mask patterns.
67
+ for (let i = 0; i < data.length; i += 64) {
68
+ const h = ((CANVAS_SEED * 2654435761) ^ (i * 1597334677)) >>> 0;
69
+ const bit = (h >>> ((i >>> 6) & 31)) & 1;
70
+ data[i] = (data[i] ^ bit) & 0xff;
71
+ }
72
+ return data;
73
+ }
74
+ // Capture originals BEFORE replacing — toDataURL must read pixels via the
75
+ // native getImageData (not the patched one), otherwise the patch double-
76
+ // applies and the second XOR cancels the first, leaving output unchanged.
77
+ const origGetImageData = CanvasRenderingContext2D.prototype.getImageData;
78
+ const origToDataURL = HTMLCanvasElement.prototype.toDataURL;
79
+ HTMLCanvasElement.prototype.toDataURL = function() {
80
+ const ctx = this.getContext('2d');
81
+ if (ctx && this.width > 0 && this.height > 0) {
82
+ try {
83
+ const img = origGetImageData.call(ctx, 0, 0, this.width, this.height);
84
+ // Snapshot the original bytes so we can restore them after encoding.
85
+ // Without this, repeated toDataURL() alternates noisy/clean: call 1
86
+ // XORs the canvas in place, call 2 reads the noisy canvas and XORs
87
+ // again (self-inverse), call 3 again, etc. Same XOR-cancellation
88
+ // class as the earlier double-apply bug, just through canvas state
89
+ // rather than method composition. The restore also keeps the
90
+ // bitmap idempotent for any downstream legitimate canvas reads.
91
+ const original = new Uint8ClampedArray(img.data);
92
+ shiftPixels(img.data);
93
+ ctx.putImageData(img, 0, 0);
94
+ const result = origToDataURL.apply(this, arguments);
95
+ img.data.set(original);
96
+ ctx.putImageData(img, 0, 0);
97
+ return result;
98
+ } catch {
99
+ // Tainted canvas (cross-origin image) — can't read; skip the nudge
100
+ // and fall through to the original call so the page sees the
101
+ // expected SecurityError instead of silent corruption.
102
+ }
103
+ }
104
+ return origToDataURL.apply(this, arguments);
105
+ };
106
+ CanvasRenderingContext2D.prototype.getImageData = function() {
107
+ const img = origGetImageData.apply(this, arguments);
108
+ shiftPixels(img.data);
109
+ return img;
110
+ };
111
+ `;
112
+
8
113
  const STEALTH_SCRIPT = `
9
114
  // Hide webdriver flag
10
- Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
115
+ ${WEBDRIVER_PATCH}
11
116
 
12
117
  // Fake plugins (headless has 0)
13
118
  Object.defineProperty(navigator, 'plugins', {
@@ -93,74 +198,7 @@ const STEALTH_SCRIPT = `
93
198
  };
94
199
  }
95
200
 
96
- // Canvas fingerprinting — sites render standard text/shapes, then read
97
- // pixels via toDataURL or getImageData. The output is stable per machine
98
- // (GPU, font rasterizer, anti-aliasing) but unique across machines, which
99
- // makes it the second-most-common fingerprint after WebGL. Defense: nudge
100
- // a few RGB channels by ±1 per session so the hash changes each visit
101
- // while the canvas still looks identical to the human eye. The per-tab
102
- // seed keeps reads stable within a session so legitimate canvas use
103
- // (image processing, games) doesn't flicker.
104
- // crypto.getRandomValues is guaranteed unique per browsing context; using
105
- // Math.random alone can collide when two fresh V8 contexts start within
106
- // microseconds of each other (real-world: parallel tests, real-world hit:
107
- // we observed it). performance.now adds a wall-clock anchor as a belt-and-
108
- // braces guard against contexts where crypto is somehow stubbed.
109
- const _seedBuf = new Uint32Array(1);
110
- crypto.getRandomValues(_seedBuf);
111
- const CANVAS_SEED = (_seedBuf[0] ^ ((performance.now() * 1e6) | 0)) >>> 0;
112
- function shiftPixels(data) {
113
- // Touch ~1 byte per 64-byte stride. The bit we XOR with is taken from a
114
- // position-dependent SLICE of a seed-mixed hash, not its low bit — a
115
- // naive 'mix & 1' collapses to only two possible outputs per seed
116
- // parity because every stride index is even (the position multiplier
117
- // is odd, so the low bit only depends on seed parity). Indexing the
118
- // hash by (i/64) mod 32 makes every stride position sample a different
119
- // bit, so two distinct seeds produce different mask patterns.
120
- for (let i = 0; i < data.length; i += 64) {
121
- const h = ((CANVAS_SEED * 2654435761) ^ (i * 1597334677)) >>> 0;
122
- const bit = (h >>> ((i >>> 6) & 31)) & 1;
123
- data[i] = (data[i] ^ bit) & 0xff;
124
- }
125
- return data;
126
- }
127
- // Capture originals BEFORE replacing — toDataURL must read pixels via the
128
- // native getImageData (not the patched one), otherwise the patch double-
129
- // applies and the second XOR cancels the first, leaving output unchanged.
130
- const origGetImageData = CanvasRenderingContext2D.prototype.getImageData;
131
- const origToDataURL = HTMLCanvasElement.prototype.toDataURL;
132
- HTMLCanvasElement.prototype.toDataURL = function() {
133
- const ctx = this.getContext('2d');
134
- if (ctx && this.width > 0 && this.height > 0) {
135
- try {
136
- const img = origGetImageData.call(ctx, 0, 0, this.width, this.height);
137
- // Snapshot the original bytes so we can restore them after encoding.
138
- // Without this, repeated toDataURL() alternates noisy/clean: call 1
139
- // XORs the canvas in place, call 2 reads the noisy canvas and XORs
140
- // again (self-inverse), call 3 again, etc. Same XOR-cancellation
141
- // class as the earlier double-apply bug, just through canvas state
142
- // rather than method composition. The restore also keeps the
143
- // bitmap idempotent for any downstream legitimate canvas reads.
144
- const original = new Uint8ClampedArray(img.data);
145
- shiftPixels(img.data);
146
- ctx.putImageData(img, 0, 0);
147
- const result = origToDataURL.apply(this, arguments);
148
- img.data.set(original);
149
- ctx.putImageData(img, 0, 0);
150
- return result;
151
- } catch {
152
- // Tainted canvas (cross-origin image) — can't read; skip the nudge
153
- // and fall through to the original call so the page sees the
154
- // expected SecurityError instead of silent corruption.
155
- }
156
- }
157
- return origToDataURL.apply(this, arguments);
158
- };
159
- CanvasRenderingContext2D.prototype.getImageData = function() {
160
- const img = origGetImageData.apply(this, arguments);
161
- shiftPixels(img.data);
162
- return img;
163
- };
201
+ ${CANVAS_NOISE_PATCH}
164
202
  `;
165
203
 
166
204
  /**
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Install ad/tracker blocking on a BiDi session. Idempotent per session is the
3
+ * caller's concern; call once at connect time before the first navigation.
4
+ *
5
+ * @param {object} bidi - BiDi client (send, subscribe, on).
6
+ * @param {object} pageOpts
7
+ * @param {boolean} [pageOpts.blockAds] - false disables the default list.
8
+ * @param {string[]} [pageOpts.blockUrls] - extra CDP-format globs; extend the
9
+ * default unless blockAds is false, in which case they're the whole list.
10
+ * @returns {Promise<void>}
11
+ */
12
+ export function applyFirefoxBlocklist(bidi: object, pageOpts?: {
13
+ blockAds?: boolean | undefined;
14
+ blockUrls?: string[] | undefined;
15
+ }): Promise<void>;
@@ -1,3 +1,37 @@
1
+ /**
2
+ * Resolve the effective blocklist from a page's blockAds/blockUrls options.
3
+ * Single-sourced across engines (CDP applyBlocklist + BiDi applyFirefoxBlocklist)
4
+ * so the merge/extend rule can't drift between them:
5
+ * - blockAds !== false → DEFAULT_BLOCKLIST plus any blockUrls (extend);
6
+ * - blockAds === false → only blockUrls (the default list is dropped);
7
+ * - neither → empty (blocking disabled).
8
+ *
9
+ * @param {object} [pageOpts]
10
+ * @param {boolean} [pageOpts.blockAds] - false drops the default list.
11
+ * @param {string[]} [pageOpts.blockUrls] - extra CDP-format globs.
12
+ * @returns {string[]} the patterns to block (possibly empty).
13
+ */
14
+ export function resolveBlocklistPatterns(pageOpts?: {
15
+ blockAds?: boolean | undefined;
16
+ blockUrls?: string[] | undefined;
17
+ }): string[];
18
+ /**
19
+ * Compile CDP-format glob patterns into a single URL-matching predicate.
20
+ *
21
+ * CDP blocks natively via Network.setBlockedURLs, which does the glob matching
22
+ * browser-side. WebDriver BiDi has no glob-capable equivalent — network.
23
+ * addIntercept's urlPatterns reject '*' outright ("forbidden character *") and
24
+ * can't express subdomain wildcards like *.doubleclick.net. So the Firefox
25
+ * path intercepts *all* requests and matches each URL here, in-process, against
26
+ * the same patterns — keeping the blocklist single-sourced across engines.
27
+ *
28
+ * Matches CDP's glob semantics: '*' = any run of characters, '?' = exactly
29
+ * one character, whole-URL (anchored) match; every other character is literal.
30
+ *
31
+ * @param {string[]} patterns - CDP-format globs (e.g. DEFAULT_BLOCKLIST).
32
+ * @returns {(url: string) => boolean} true when `url` matches any pattern.
33
+ */
34
+ export function makeBlockMatcher(patterns: string[]): (url: string) => boolean;
1
35
  /**
2
36
  * blocklist.js — Ad/tracker URL patterns for CDP Network.setBlockedURLs.
3
37
  *
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Try to dismiss a cookie consent dialog in a reconstructed Firefox AX tree.
3
+ *
4
+ * @param {object} root - Spliced AX tree root (from firefox-page's buildTree).
5
+ * @param {(ref: string) => Promise<void>} click - Clicks the element for a ref
6
+ * (firefox-page injects one that routes to pointerClick / performActions).
7
+ * @returns {Promise<boolean>} true if an accept button was found and clicked.
8
+ */
9
+ export function dismissConsentFirefox(root: object, click: (ref: string) => Promise<void>): Promise<boolean>;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * consent-patterns.js — Shared cookie-consent detection data.
3
+ *
4
+ * The engine-agnostic half of consent handling: the multilingual regex sets
5
+ * and dialog roles used to recognise a consent dialog and its "accept" button.
6
+ * Both the CDP walker (consent.js) and the BiDi walker (consent-firefox.js)
7
+ * import these so the language coverage is single-sourced — a new language or
8
+ * pattern is added once and both engines get it.
9
+ */
10
+ export const ACCEPT_PATTERNS: RegExp[];
11
+ export const DIALOG_ROLES: Set<string>;
12
+ export const CONSENT_DIALOG_HINTS: RegExp[];
package/types/daemon.d.ts CHANGED
@@ -10,6 +10,28 @@
10
10
  * @returns {string[]}
11
11
  */
12
12
  export function buildDaemonArgs(opts: object, absDir: string, initialUrl: string | undefined, cliPath: string): string[];
13
+ /**
14
+ * Wire Firefox/BiDi console + network capture onto the daemon's log arrays.
15
+ * The BiDi analogue of the CDP `Runtime.consoleAPICalled` / `Network.*`
16
+ * capture — measured event shapes: `log.entryAdded` carries {method, level,
17
+ * args:[{type,value}], text}; the network events key in-flight requests by
18
+ * `request.request` and expose `response.{status,statusText,mimeType}` /
19
+ * top-level `errorText`. Pure + exported so the shape mapping (warn→warning
20
+ * normalization, arg extraction, orphan-safe response/error pairing) is
21
+ * unit-testable against a fake bidi without launching Firefox.
22
+ *
23
+ * @param {object} bidi - BiDi client (async .subscribe(), .on(method, cb))
24
+ * @param {object} sinks
25
+ * @param {Array} sinks.consoleLogs - push-target for console entries
26
+ * @param {Array} sinks.networkLogs - push-target for completed/failed requests
27
+ * @param {Map} sinks.pendingRequests - in-flight request bookkeeping
28
+ * @returns {Promise<void>}
29
+ */
30
+ export function attachBiDiCapture(bidi: object, { consoleLogs, networkLogs, pendingRequests }: {
31
+ consoleLogs: any[];
32
+ networkLogs: any[];
33
+ pendingRequests: Map<any, any>;
34
+ }): Promise<void>;
13
35
  /**
14
36
  * Spawn a detached child process that runs the daemon.
15
37
  * Parent polls for session.json, then exits.
@@ -6,6 +6,7 @@
6
6
  * @param {{allowLocalUrls?: boolean, blockPrivateNetwork?: boolean}} [opts.urlGuard] - Navigation safety policy, applied on every goto().
7
7
  * @param {string} [opts.uploadDir] - When set, upload() rejects files outside this dir.
8
8
  * @param {boolean} [opts.incognito=false] - Clean session: injectCookies() is a no-op.
9
+ * @param {boolean} [opts.consent=true] - Auto-dismiss cookie consent dialogs after goto().
9
10
  * @returns {Promise<object>} page object
10
11
  */
11
12
  export function createFirefoxPage(bidi: object, opts?: {
@@ -16,4 +17,5 @@ export function createFirefoxPage(bidi: object, opts?: {
16
17
  } | undefined;
17
18
  uploadDir?: string | undefined;
18
19
  incognito?: boolean | undefined;
20
+ consent?: boolean | undefined;
19
21
  }): Promise<object>;
@@ -1,12 +1,5 @@
1
1
  /**
2
- * network-idle.js wait until the page's network has been idle for N ms.
3
- *
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.
8
- */
9
- /**
2
+ * CDP: wait for network idle over `Network.*` events.
10
3
  * @param {object} session - CDP session-scoped handle with .on() returning unsub
11
4
  * @param {object} [opts]
12
5
  * @param {number} [opts.timeout=30000] - Max wait time before reject
@@ -17,3 +10,18 @@ export function waitForNetworkIdle(session: object, opts?: {
17
10
  timeout?: number | undefined;
18
11
  idle?: number | undefined;
19
12
  }): Promise<void>;
13
+ /**
14
+ * Firefox/BiDi: wait for network idle over `network.*` events. Subscribes the
15
+ * events first (idempotent — safe even if the daemon already subscribed for
16
+ * log capture), then runs the same orphan-resilient idle loop. The in-flight
17
+ * key is `params.request.request` (the BiDi request id).
18
+ * @param {object} bidi - BiDi client with async .subscribe() and .on() returning unsub
19
+ * @param {object} [opts]
20
+ * @param {number} [opts.timeout=30000] - Max wait time before reject
21
+ * @param {number} [opts.idle=500] - Required idle duration before resolve
22
+ * @returns {Promise<void>}
23
+ */
24
+ export function waitForNetworkIdleBiDi(bidi: object, opts?: {
25
+ timeout?: number | undefined;
26
+ idle?: number | undefined;
27
+ }): Promise<void>;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Register the Firefox stealth patches so they run before any page script.
3
+ * Must be called before the first navigation. addPreloadScript is global
4
+ * (applies to every browsing context / navigation), so a single registration
5
+ * covers the whole session.
6
+ *
7
+ * @param {object} bidi - BiDi client from createBiDi()
8
+ */
9
+ export function applyFirefoxStealth(bidi: object): Promise<void>;
@@ -12,3 +12,34 @@
12
12
  * @param {object} session - Session-scoped CDP handle
13
13
  */
14
14
  export function applyStealth(session: object): Promise<void>;
15
+ /**
16
+ * stealth.js — Anti-detection patches for headless Chromium.
17
+ *
18
+ * Uses Page.addScriptToEvaluateOnNewDocument so patches run before
19
+ * any page scripts (unlike Runtime.evaluate which runs after).
20
+ */
21
+ /**
22
+ * navigator.webdriver hiding — the one automation tell shared by every engine
23
+ * (Chromium headless AND Firefox-under-BiDi both report `true`). Split out so
24
+ * the Firefox stealth path (stealth-firefox.js) reuses the exact same patch
25
+ * instead of duplicating it. Measured baseline on both engines: `true`.
26
+ *
27
+ * A naive `Object.defineProperty(navigator, 'webdriver', …)` hides the *value*
28
+ * but leaves an advanced tell: it creates an OWN property on the instance, so
29
+ * `navigator.hasOwnProperty('webdriver')` becomes `true` where a real browser
30
+ * (property lives on Navigator.prototype) reports `false` — sophisticated
31
+ * anti-bot checks (e.g. sannysoft's "WebDriver New") catch that inconsistency.
32
+ * So we DELETE the getter off the prototype instead: then `navigator.webdriver`
33
+ * is `undefined` AND `'webdriver' in navigator` AND `hasOwnProperty` are both
34
+ * `false`, matching a stock browser. POC-verified on Chromium and Firefox
35
+ * (own/in both false, prototype descriptor gone). The defineProperty fallback
36
+ * only fires if the descriptor is somehow non-configurable (delete a no-op).
37
+ */
38
+ export const WEBDRIVER_PATCH: "\n try {\n const proto = Object.getPrototypeOf(navigator);\n const desc = proto && Object.getOwnPropertyDescriptor(proto, 'webdriver');\n if (desc && desc.configurable) delete proto.webdriver;\n if (navigator.webdriver) {\n Object.defineProperty(navigator, 'webdriver', { get: () => undefined, configurable: true });\n }\n } catch {}\n";
39
+ /**
40
+ * Canvas-fingerprint noise — browser-agnostic (standard Canvas2D API), so both
41
+ * the Chromium and Firefox stealth scripts compose it. This block carries the
42
+ * subtle double-XOR-cancellation fixes (see git history / project memory); keep
43
+ * it SINGLE-SOURCED here so a fix never has to be made in two places.
44
+ */
45
+ export const CANVAS_NOISE_PATCH: "\n // Canvas fingerprinting \u2014 sites render standard text/shapes, then read\n // pixels via toDataURL or getImageData. The output is stable per machine\n // (GPU, font rasterizer, anti-aliasing) but unique across machines, which\n // makes it the second-most-common fingerprint after WebGL. Defense: nudge\n // a few RGB channels by \u00B11 per session so the hash changes each visit\n // while the canvas still looks identical to the human eye. The per-tab\n // seed keeps reads stable within a session so legitimate canvas use\n // (image processing, games) doesn't flicker.\n // crypto.getRandomValues is guaranteed unique per browsing context; using\n // Math.random alone can collide when two fresh V8 contexts start within\n // microseconds of each other (real-world: parallel tests, real-world hit:\n // we observed it). performance.now adds a wall-clock anchor as a belt-and-\n // braces guard against contexts where crypto is somehow stubbed.\n const _seedBuf = new Uint32Array(1);\n crypto.getRandomValues(_seedBuf);\n const CANVAS_SEED = (_seedBuf[0] ^ ((performance.now() * 1e6) | 0)) >>> 0;\n function shiftPixels(data) {\n // Touch ~1 byte per 64-byte stride. The bit we XOR with is taken from a\n // position-dependent SLICE of a seed-mixed hash, not its low bit \u2014 a\n // naive 'mix & 1' collapses to only two possible outputs per seed\n // parity because every stride index is even (the position multiplier\n // is odd, so the low bit only depends on seed parity). Indexing the\n // hash by (i/64) mod 32 makes every stride position sample a different\n // bit, so two distinct seeds produce different mask patterns.\n for (let i = 0; i < data.length; i += 64) {\n const h = ((CANVAS_SEED * 2654435761) ^ (i * 1597334677)) >>> 0;\n const bit = (h >>> ((i >>> 6) & 31)) & 1;\n data[i] = (data[i] ^ bit) & 0xff;\n }\n return data;\n }\n // Capture originals BEFORE replacing \u2014 toDataURL must read pixels via the\n // native getImageData (not the patched one), otherwise the patch double-\n // applies and the second XOR cancels the first, leaving output unchanged.\n const origGetImageData = CanvasRenderingContext2D.prototype.getImageData;\n const origToDataURL = HTMLCanvasElement.prototype.toDataURL;\n HTMLCanvasElement.prototype.toDataURL = function() {\n const ctx = this.getContext('2d');\n if (ctx && this.width > 0 && this.height > 0) {\n try {\n const img = origGetImageData.call(ctx, 0, 0, this.width, this.height);\n // Snapshot the original bytes so we can restore them after encoding.\n // Without this, repeated toDataURL() alternates noisy/clean: call 1\n // XORs the canvas in place, call 2 reads the noisy canvas and XORs\n // again (self-inverse), call 3 again, etc. Same XOR-cancellation\n // class as the earlier double-apply bug, just through canvas state\n // rather than method composition. The restore also keeps the\n // bitmap idempotent for any downstream legitimate canvas reads.\n const original = new Uint8ClampedArray(img.data);\n shiftPixels(img.data);\n ctx.putImageData(img, 0, 0);\n const result = origToDataURL.apply(this, arguments);\n img.data.set(original);\n ctx.putImageData(img, 0, 0);\n return result;\n } catch {\n // Tainted canvas (cross-origin image) \u2014 can't read; skip the nudge\n // and fall through to the original call so the page sees the\n // expected SecurityError instead of silent corruption.\n }\n }\n return origToDataURL.apply(this, arguments);\n };\n CanvasRenderingContext2D.prototype.getImageData = function() {\n const img = origGetImageData.apply(this, arguments);\n shiftPixels(img.data);\n return img;\n };\n";