barebrowse 0.13.0 → 0.15.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/bidi.js ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * bidi.js — Minimal WebDriver BiDi client over WebSocket (Firefox transport).
3
+ *
4
+ * The W3C-standard successor to CDP. CDP was deprecated in Firefox, so Firefox
5
+ * is driven over BiDi instead. This is the BiDi analogue of cdp.js: same
6
+ * JSON-RPC-over-WebSocket shape (id/method/params → result), same `ws`
7
+ * dependency and 256 MB maxPayload (a full AX snapshot of a large page is
8
+ * returned as one big string from script.evaluate — see ax-snapshot.js — and
9
+ * would blow the built-in WebSocket cap exactly as getFullAXTree did on CDP).
10
+ *
11
+ * Differences from CDP that shape this client:
12
+ * - A session must be created explicitly (`session.new`) before any command.
13
+ * - Events must be subscribed to (`session.subscribe`); we lean on
14
+ * command results (navigate's `wait:'complete'`) instead where possible.
15
+ * - Errors arrive as `{ type:'error', error, message }`, not `{ error:{} }`.
16
+ * - Everything is scoped by `context` (a browsing-context id), not sessionId.
17
+ */
18
+
19
+ import WebSocket from 'ws';
20
+
21
+ /** Lift the message ceiling well past any realistic AX/DOM payload. */
22
+ const MAX_PAYLOAD = 256 * 1024 * 1024; // 256 MB
23
+
24
+ /**
25
+ * Create a BiDi client connected to the given /session WebSocket URL and open
26
+ * a session. Firefox prints its BiDi endpoint to stderr as
27
+ * "WebDriver BiDi listening on ws://HOST:PORT"; the direct-connection socket
28
+ * (no WebDriver-classic handshake) lives at that URL + "/session".
29
+ *
30
+ * @param {string} wsUrl - BiDi session WebSocket URL (ws://HOST:PORT/session)
31
+ * @returns {Promise<object>} BiDi client ({ send, evaluate, on, once, subscribe, sessionId, close })
32
+ */
33
+ export async function createBiDi(wsUrl) {
34
+ const ws = new WebSocket(wsUrl, { maxPayload: MAX_PAYLOAD, perMessageDeflate: false });
35
+ let nextId = 1;
36
+ const pending = new Map(); // id → { resolve, reject }
37
+ const listeners = new Map(); // "method" → Set<callback>
38
+
39
+ /** @type {Promise<void>} */
40
+ const connected = new Promise((resolve, reject) => {
41
+ const timeout = setTimeout(() => reject(new Error('BiDi connection timeout (5s)')), 5000);
42
+ ws.onopen = () => { clearTimeout(timeout); resolve(); };
43
+ ws.onerror = (e) => {
44
+ clearTimeout(timeout);
45
+ reject(new Error(`BiDi WebSocket connection failed: ${e.message || 'unknown error'}`));
46
+ };
47
+ });
48
+ await connected;
49
+
50
+ ws.onmessage = (event) => {
51
+ const msg = JSON.parse(typeof event.data === 'string' ? event.data : event.data.toString());
52
+
53
+ // Command response (has id + type 'success' | 'error')
54
+ if (msg.id !== undefined && pending.has(msg.id)) {
55
+ const handler = pending.get(msg.id);
56
+ pending.delete(msg.id);
57
+ if (msg.type === 'error') {
58
+ handler.reject(new Error(`BiDi error: ${msg.error} — ${msg.message || ''}`.trim()));
59
+ } else {
60
+ handler.resolve(msg.result);
61
+ }
62
+ return;
63
+ }
64
+
65
+ // Event (type 'event', has method + params)
66
+ if (msg.type === 'event' && msg.method) {
67
+ const set = listeners.get(msg.method);
68
+ if (set) for (const cb of set) cb(msg.params);
69
+ }
70
+ };
71
+
72
+ ws.onclose = () => {
73
+ for (const [id, handler] of pending) {
74
+ handler.reject(new Error('BiDi WebSocket closed'));
75
+ pending.delete(id);
76
+ }
77
+ };
78
+
79
+ const client = {
80
+ /**
81
+ * Send a BiDi command and wait for its result.
82
+ * @param {string} method - e.g. 'browsingContext.navigate'
83
+ * @param {object} [params]
84
+ * @returns {Promise<object>} result
85
+ */
86
+ send(method, params = {}) {
87
+ const id = nextId++;
88
+ return new Promise((resolve, reject) => {
89
+ pending.set(id, { resolve, reject });
90
+ ws.send(JSON.stringify({ id, method, params }));
91
+ });
92
+ },
93
+
94
+ /**
95
+ * Evaluate an expression in a browsing context and return its value.
96
+ * Throws on an in-page exception (BiDi reports these as a `success`
97
+ * envelope with `type:'exception'`, unlike a protocol error).
98
+ * @param {string} context - browsing-context id
99
+ * @param {string} expression - JS source to evaluate
100
+ * @param {boolean} [awaitPromise=true]
101
+ * @returns {Promise<*>} the deserialized primitive value (result.value)
102
+ */
103
+ async evaluate(context, expression, awaitPromise = true) {
104
+ const res = await client.send('script.evaluate', {
105
+ expression, target: { context }, awaitPromise,
106
+ });
107
+ if (res.type === 'exception') {
108
+ const d = res.exceptionDetails;
109
+ throw new Error(`BiDi script exception: ${d?.text || d?.exception?.value || 'unknown'}`);
110
+ }
111
+ return res.result?.value;
112
+ },
113
+
114
+ /** Subscribe to BiDi events by method name (required before events fire). */
115
+ async subscribe(events) {
116
+ await client.send('session.subscribe', { events });
117
+ },
118
+
119
+ /** Register an event listener. Returns an unsubscribe function. */
120
+ on(method, callback) {
121
+ if (!listeners.has(method)) listeners.set(method, new Set());
122
+ listeners.get(method).add(callback);
123
+ return () => listeners.get(method)?.delete(callback);
124
+ },
125
+
126
+ /** Resolve once a named event fires (or reject on timeout). */
127
+ once(method, timeout = 30000) {
128
+ return new Promise((resolve, reject) => {
129
+ const timer = setTimeout(() => { unsub(); reject(new Error(`Timeout waiting for BiDi event: ${method}`)); }, timeout);
130
+ const unsub = client.on(method, (params) => { clearTimeout(timer); unsub(); resolve(params); });
131
+ });
132
+ },
133
+
134
+ sessionId: null,
135
+ close() { ws.close(); },
136
+ };
137
+
138
+ // Open the session. capabilities:{} accepts Firefox's defaults.
139
+ const session = await client.send('session.new', { capabilities: {} });
140
+ client.sessionId = session.sessionId;
141
+ client.capabilities = session.capabilities;
142
+ return client;
143
+ }
package/src/daemon.js CHANGED
@@ -31,24 +31,25 @@ function tokenMatches(expected, got) {
31
31
  const SESSION_FILE = 'session.json';
32
32
 
33
33
  /**
34
- * Spawn a detached child process that runs the daemon.
35
- * Parent polls for session.json, then exits.
34
+ * Build the argv for the detached daemon child. Pure + exported so the
35
+ * flag-forwarding contract is unit-testable: a forward that's silently
36
+ * dropped here (as `--incognito` was in v0.15.0, turning `open --incognito`
37
+ * into a fully-authenticated session) becomes a failing test, not a leak.
38
+ * @param {object} opts - the session options parsed in cli.js cmdOpen
39
+ * @param {string} absDir - resolved output directory
40
+ * @param {string|undefined} initialUrl - URL to navigate on start (may be undefined)
41
+ * @param {string} cliPath - path to cli.js for the child to run
42
+ * @returns {string[]}
36
43
  */
37
- export async function startDaemon(opts, outputDir, initialUrl) {
38
- const absDir = resolve(outputDir);
39
- mkdirSync(absDir, { recursive: true, mode: 0o700 });
40
-
41
- // Clean stale session
42
- const sessionPath = join(absDir, SESSION_FILE);
43
- if (existsSync(sessionPath)) unlinkSync(sessionPath);
44
-
45
- // Build child args
46
- const args = [join(import.meta.dirname, '..', 'cli.js'), '--daemon-internal'];
44
+ export function buildDaemonArgs(opts, absDir, initialUrl, cliPath) {
45
+ const args = [cliPath, '--daemon-internal'];
47
46
  args.push('--output-dir', absDir);
48
47
  if (initialUrl) args.push('--url', initialUrl);
48
+ if (opts.engine) args.push('--engine', opts.engine);
49
49
  if (opts.mode) args.push('--mode', opts.mode);
50
50
  if (opts.port) args.push('--port', String(opts.port));
51
51
  if (opts.cookies === false) args.push('--no-cookies');
52
+ if (opts.incognito) args.push('--incognito');
52
53
  if (opts.browser) args.push('--browser', opts.browser);
53
54
  if (opts.timeout) args.push('--timeout', String(opts.timeout));
54
55
  if (opts.pruneMode) args.push('--prune-mode', opts.pruneMode);
@@ -63,6 +64,23 @@ export async function startDaemon(opts, outputDir, initialUrl) {
63
64
  }
64
65
  if (opts.blockPrivateNetwork) args.push('--block-private-network');
65
66
  if (opts.uploadDir) args.push('--upload-dir', opts.uploadDir);
67
+ return args;
68
+ }
69
+
70
+ /**
71
+ * Spawn a detached child process that runs the daemon.
72
+ * Parent polls for session.json, then exits.
73
+ */
74
+ export async function startDaemon(opts, outputDir, initialUrl) {
75
+ const absDir = resolve(outputDir);
76
+ mkdirSync(absDir, { recursive: true, mode: 0o700 });
77
+
78
+ // Clean stale session
79
+ const sessionPath = join(absDir, SESSION_FILE);
80
+ if (existsSync(sessionPath)) unlinkSync(sessionPath);
81
+
82
+ // Build child args (pure + testable — see buildDaemonArgs).
83
+ const args = buildDaemonArgs(opts, absDir, initialUrl, join(import.meta.dirname, '..', 'cli.js'));
66
84
 
67
85
  const child = spawn(process.execPath, args, {
68
86
  detached: true,
@@ -104,6 +122,7 @@ export async function runDaemon(opts, outputDir, initialUrl) {
104
122
 
105
123
  // Connect to browser
106
124
  const page = await connect({
125
+ engine: opts.engine,
107
126
  mode: opts.mode || 'headless',
108
127
  port: opts.port ? Number(opts.port) : undefined,
109
128
  consent: opts.consent,
@@ -115,55 +134,63 @@ export async function runDaemon(opts, outputDir, initialUrl) {
115
134
  blockUrls: opts.blockUrls,
116
135
  blockPrivateNetwork: opts.blockPrivateNetwork,
117
136
  uploadDir: opts.uploadDir,
137
+ // incognito neuters injectCookies() session-wide; cookies:false (below)
138
+ // is the legacy per-open equivalent. Either yields a logged-out session.
139
+ incognito: opts.incognito || opts.cookies === false,
118
140
  });
119
141
 
120
- // Console log capture
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.)
121
146
  const consoleLogs = [];
122
- await page.cdp.send('Runtime.enable');
123
- page.cdp.on('Runtime.consoleAPICalled', (params) => {
124
- consoleLogs.push({
125
- type: params.type,
126
- timestamp: new Date().toISOString(),
127
- args: params.args.map((a) => a.value ?? a.description ?? a.type),
128
- });
129
- });
130
-
131
- // Network log capture (Network.enable already called by connect)
132
147
  const networkLogs = [];
133
148
  const pendingRequests = new Map();
134
149
 
135
- page.cdp.on('Network.requestWillBeSent', (params) => {
136
- pendingRequests.set(params.requestId, {
137
- url: params.request.url,
138
- method: params.request.method,
139
- timestamp: new Date().toISOString(),
150
+ if (page.cdp) {
151
+ await page.cdp.send('Runtime.enable');
152
+ page.cdp.on('Runtime.consoleAPICalled', (params) => {
153
+ consoleLogs.push({
154
+ type: params.type,
155
+ timestamp: new Date().toISOString(),
156
+ args: params.args.map((a) => a.value ?? a.description ?? a.type),
157
+ });
140
158
  });
141
- });
142
159
 
143
- page.cdp.on('Network.responseReceived', (params) => {
144
- const req = pendingRequests.get(params.requestId);
145
- if (req) {
146
- networkLogs.push({
147
- ...req,
148
- status: params.response.status,
149
- statusText: params.response.statusText,
150
- mimeType: params.response.mimeType,
160
+ // Network log capture (Network.enable already called by connect)
161
+ page.cdp.on('Network.requestWillBeSent', (params) => {
162
+ pendingRequests.set(params.requestId, {
163
+ url: params.request.url,
164
+ method: params.request.method,
165
+ timestamp: new Date().toISOString(),
151
166
  });
152
- pendingRequests.delete(params.requestId);
153
- }
154
- });
167
+ });
155
168
 
156
- page.cdp.on('Network.loadingFailed', (params) => {
157
- const req = pendingRequests.get(params.requestId);
158
- if (req) {
159
- networkLogs.push({
160
- ...req,
161
- status: 0,
162
- error: params.errorText,
163
- });
164
- pendingRequests.delete(params.requestId);
165
- }
166
- });
169
+ page.cdp.on('Network.responseReceived', (params) => {
170
+ const req = pendingRequests.get(params.requestId);
171
+ if (req) {
172
+ networkLogs.push({
173
+ ...req,
174
+ status: params.response.status,
175
+ statusText: params.response.statusText,
176
+ mimeType: params.response.mimeType,
177
+ });
178
+ pendingRequests.delete(params.requestId);
179
+ }
180
+ });
181
+
182
+ page.cdp.on('Network.loadingFailed', (params) => {
183
+ const req = pendingRequests.get(params.requestId);
184
+ if (req) {
185
+ networkLogs.push({
186
+ ...req,
187
+ status: 0,
188
+ error: params.errorText,
189
+ });
190
+ pendingRequests.delete(params.requestId);
191
+ }
192
+ });
193
+ }
167
194
 
168
195
  // Navigate to initial URL if provided
169
196
  if (initialUrl) {
@@ -316,6 +343,20 @@ export async function runDaemon(opts, outputDir, initialUrl) {
316
343
  },
317
344
 
318
345
  async eval({ expression }) {
346
+ // Firefox/BiDi has no page.cdp — evaluate over BiDi in the active context.
347
+ if (!page.cdp) {
348
+ try {
349
+ // BiDi's raw result.value serializes objects into a {type,value}
350
+ // entries structure, not a plain object (CDP's returnByValue does).
351
+ // Stringify + parse in-page so an object/array eval matches the CDP
352
+ // shape (same trick readable() uses). `undefined` → JSON `null`.
353
+ const wrapped = `(async () => { const v = await (${expression}); return JSON.stringify(v === undefined ? null : v); })()`;
354
+ const raw = await page.bidi.evaluate(page.context, wrapped, true);
355
+ return { ok: true, value: raw == null ? null : JSON.parse(raw) };
356
+ } catch (err) {
357
+ return { ok: false, error: err.message || 'eval error' };
358
+ }
359
+ }
319
360
  const result = await page.cdp.send('Runtime.evaluate', {
320
361
  expression,
321
362
  returnByValue: true,