@monostate/node-scraper 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,685 @@
1
+ import { getLightPandaServer, stopLightPandaServer } from './lightpanda-server.js';
2
+ import browserPool from './browser-pool.js';
3
+
4
+ const FALLBACK_REASONS = {
5
+ SCREENSHOT: 'screenshot_requested',
6
+ CDP_ERROR: 'cdp_protocol_error',
7
+ BOT_DETECTION: 'bot_detection',
8
+ NAVIGATION_FAILED: 'navigation_after_click_failed',
9
+ METHOD_NOT_SUPPORTED: 'method_not_supported',
10
+ };
11
+
12
+ export class BrowserSession {
13
+ /**
14
+ * @param {object} options
15
+ * @param {'headless'|'visual'|'auto'|'computer-use'} options.mode - 'headless' (LightPanda), 'visual' (Chrome visible), 'auto' (LP with Chrome fallback), 'computer-use' (provider with Xvfb+VNC)
16
+ * @param {number} options.timeout - Navigation timeout in ms (default: 15000)
17
+ * @param {string} options.userAgent - Custom user agent
18
+ * @param {string} options.lightpandaPath - Path to LightPanda binary
19
+ * @param {boolean} options.verbose - Enable logging
20
+ * @param {import('./computer-use-provider.js').ComputerUseProvider} options.provider - Required for computer-use mode
21
+ */
22
+ constructor(options = {}) {
23
+ this.mode = options.mode || 'auto';
24
+ this.activeBackend = null; // 'lightpanda' | 'chrome' | 'computer-use'
25
+ this.timeout = options.timeout || 15000;
26
+ this.userAgent = options.userAgent || 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
27
+ this.lightpandaPath = options.lightpandaPath;
28
+ this.verbose = options.verbose || false;
29
+
30
+ this.browser = null;
31
+ this.context = null;
32
+ this.page = null;
33
+ this._chromeBrowser = null; // reference for pool release
34
+ this._connected = false;
35
+ this._fallbackCount = 0;
36
+
37
+ // Computer-use provider (Xvfb + Chrome + xdotool + optional VNC)
38
+ this.provider = options.provider || null;
39
+ this._providerInfo = null;
40
+
41
+ this.history = []; // action log for debugging
42
+ }
43
+
44
+ // ── Connection ──────────────────────────────────────────────
45
+
46
+ async connect() {
47
+ if (this._connected) return this;
48
+
49
+ if (this.mode === 'computer-use') {
50
+ await this._connectViaProvider();
51
+ } else if (this.mode === 'visual') {
52
+ await this._connectChromeVisual();
53
+ } else {
54
+ // 'headless' or 'auto' — start with LightPanda
55
+ try {
56
+ await this._connectLightPanda();
57
+ } catch (err) {
58
+ if (this.mode === 'auto') {
59
+ this._log(`LightPanda unavailable (${err.message}), falling back to Chrome`);
60
+ await this._connectChrome();
61
+ } else {
62
+ throw err;
63
+ }
64
+ }
65
+ }
66
+
67
+ this._connected = true;
68
+ return this;
69
+ }
70
+
71
+ async _connectLightPanda() {
72
+ const server = getLightPandaServer(this.lightpandaPath);
73
+ const endpoint = await server.start();
74
+
75
+ const puppeteer = await this._getPuppeteer();
76
+ this.browser = await puppeteer.connect({ browserWSEndpoint: endpoint });
77
+ this.context = await this.browser.createBrowserContext();
78
+ this.page = await this.context.newPage();
79
+
80
+ await this.page.setUserAgent(this.userAgent);
81
+ this.activeBackend = 'lightpanda';
82
+ this._log('Connected to LightPanda CDP');
83
+ }
84
+
85
+ async _connectChrome() {
86
+ this._chromeBrowser = await browserPool.getBrowser();
87
+ this.browser = this._chromeBrowser;
88
+ this.page = await this.browser.newPage();
89
+
90
+ await this.page.setUserAgent(this.userAgent);
91
+ await this.page.setViewport({ width: 1280, height: 800 });
92
+ this.activeBackend = 'chrome';
93
+ this._log('Connected to Chrome');
94
+ }
95
+
96
+ async _connectChromeVisual() {
97
+ const puppeteer = await this._getPuppeteer();
98
+ this.browser = await puppeteer.launch({
99
+ headless: false,
100
+ args: [
101
+ '--no-sandbox',
102
+ '--disable-setuid-sandbox',
103
+ '--disable-dev-shm-usage',
104
+ '--window-size=1280,800',
105
+ ],
106
+ });
107
+ this.page = await this.browser.newPage();
108
+ await this.page.setUserAgent(this.userAgent);
109
+ await this.page.setViewport({ width: 1280, height: 800 });
110
+ this.activeBackend = 'chrome';
111
+ this._log('Connected to Chrome (visual, headless:false)');
112
+ }
113
+
114
+ async _connectViaProvider() {
115
+ if (!this.provider) {
116
+ throw new Error('computer-use mode requires a provider. Pass { provider: new LocalProvider() }');
117
+ }
118
+ this._providerInfo = await this.provider.start();
119
+
120
+ const puppeteer = await this._getPuppeteer();
121
+ this.browser = await puppeteer.connect({
122
+ browserWSEndpoint: this._providerInfo.cdpUrl,
123
+ });
124
+
125
+ const pages = await this.browser.pages();
126
+ this.page = pages[0] || await this.browser.newPage();
127
+ await this.page.setViewport({
128
+ width: this._providerInfo.screenSize.width,
129
+ height: this._providerInfo.screenSize.height,
130
+ });
131
+ this.activeBackend = 'computer-use';
132
+ this._log('Connected via ComputerUseProvider');
133
+ }
134
+
135
+ // ── Navigation ──────────────────────────────────────────────
136
+
137
+ async goto(url) {
138
+ this._ensureConnected();
139
+ try {
140
+ await this.page.goto(url, {
141
+ waitUntil: 'networkidle0',
142
+ timeout: this.timeout,
143
+ });
144
+ this._logAction('goto', { url });
145
+ return { success: true, url: this.page.url() };
146
+ } catch (err) {
147
+ if (await this._shouldFallback(err)) {
148
+ await this._fallbackToChrome(FALLBACK_REASONS.CDP_ERROR);
149
+ return this.goto(url);
150
+ }
151
+ throw err;
152
+ }
153
+ }
154
+
155
+ async goBack() {
156
+ this._ensureConnected();
157
+ await this.page.goBack({ waitUntil: 'networkidle0', timeout: this.timeout });
158
+ this._logAction('goBack');
159
+ }
160
+
161
+ async goForward() {
162
+ this._ensureConnected();
163
+ await this.page.goForward({ waitUntil: 'networkidle0', timeout: this.timeout });
164
+ this._logAction('goForward');
165
+ }
166
+
167
+ // ── Page Interactions ───────────────────────────────────────
168
+
169
+ async click(selector, options = {}) {
170
+ this._ensureConnected();
171
+ try {
172
+ await this.page.waitForSelector(selector, { timeout: options.timeout || this.timeout });
173
+ const urlBefore = this.page.url();
174
+ await this.page.click(selector);
175
+
176
+ // If navigation expected, wait briefly
177
+ if (options.waitForNavigation !== false) {
178
+ try {
179
+ await this.page.waitForNavigation({ timeout: 3000, waitUntil: 'networkidle0' }).catch(() => {});
180
+ } catch {
181
+ // No navigation happened, that's fine
182
+ }
183
+ }
184
+
185
+ this._logAction('click', { selector });
186
+
187
+ // Check for LP's known click-navigation bug
188
+ if (this.activeBackend === 'lightpanda') {
189
+ const urlAfter = this.page.url();
190
+ if (options.expectNavigation && urlBefore === urlAfter) {
191
+ this._log('Click did not trigger expected navigation — falling back to Chrome');
192
+ await this._fallbackToChrome(FALLBACK_REASONS.NAVIGATION_FAILED);
193
+ return this.click(selector, { ...options, _retried: true });
194
+ }
195
+ }
196
+
197
+ return { success: true, url: this.page.url() };
198
+ } catch (err) {
199
+ if (await this._shouldFallback(err)) {
200
+ await this._fallbackToChrome(FALLBACK_REASONS.CDP_ERROR);
201
+ return this.click(selector, options);
202
+ }
203
+ throw err;
204
+ }
205
+ }
206
+
207
+ async type(selector, text, options = {}) {
208
+ this._ensureConnected();
209
+ try {
210
+ await this.page.waitForSelector(selector, { timeout: options.timeout || this.timeout });
211
+
212
+ if (options.clear) {
213
+ await this.page.click(selector, { clickCount: 3 });
214
+ await this.page.keyboard.press('Backspace');
215
+ }
216
+
217
+ await this.page.type(selector, text, { delay: options.delay || 0 });
218
+ this._logAction('type', { selector, text: text.substring(0, 20) + (text.length > 20 ? '...' : '') });
219
+ return { success: true };
220
+ } catch (err) {
221
+ if (await this._shouldFallback(err)) {
222
+ await this._fallbackToChrome(FALLBACK_REASONS.CDP_ERROR);
223
+ return this.type(selector, text, options);
224
+ }
225
+ throw err;
226
+ }
227
+ }
228
+
229
+ async scroll(direction = 'down', amount = 500) {
230
+ this._ensureConnected();
231
+ const deltaY = direction === 'up' ? -amount : amount;
232
+ await this.page.evaluate((dy) => window.scrollBy(0, dy), deltaY);
233
+ this._logAction('scroll', { direction, amount });
234
+ return { success: true };
235
+ }
236
+
237
+ async hover(selector) {
238
+ this._ensureConnected();
239
+ if (this.activeBackend === 'lightpanda') {
240
+ // LP doesn't support mouseMoved — fall back
241
+ if (this.mode === 'auto') {
242
+ await this._fallbackToChrome(FALLBACK_REASONS.METHOD_NOT_SUPPORTED);
243
+ return this.hover(selector);
244
+ }
245
+ throw new Error('hover() not supported in LightPanda mode');
246
+ }
247
+
248
+ await this.page.waitForSelector(selector, { timeout: this.timeout });
249
+ await this.page.hover(selector);
250
+ this._logAction('hover', { selector });
251
+ return { success: true };
252
+ }
253
+
254
+ async select(selector, ...values) {
255
+ this._ensureConnected();
256
+ await this.page.waitForSelector(selector, { timeout: this.timeout });
257
+ await this.page.select(selector, ...values);
258
+ this._logAction('select', { selector, values });
259
+ return { success: true };
260
+ }
261
+
262
+ async pressKey(key) {
263
+ this._ensureConnected();
264
+ await this.page.keyboard.press(key);
265
+ this._logAction('pressKey', { key });
266
+ return { success: true };
267
+ }
268
+
269
+ // ── Content Extraction ──────────────────────────────────────
270
+
271
+ async extractContent() {
272
+ this._ensureConnected();
273
+ return this.page.evaluate(() => {
274
+ const title = document.title;
275
+ const metaDescription = document.querySelector('meta[name="description"]')?.content || '';
276
+ const headings = Array.from(document.querySelectorAll('h1, h2, h3'))
277
+ .map(h => ({ level: h.tagName.toLowerCase(), text: h.textContent.trim() }))
278
+ .filter(h => h.text.length > 0)
279
+ .slice(0, 20);
280
+ const paragraphs = Array.from(document.querySelectorAll('p'))
281
+ .map(p => p.textContent.trim())
282
+ .filter(t => t.length > 20)
283
+ .slice(0, 15);
284
+ const links = Array.from(document.querySelectorAll('a[href]'))
285
+ .map(a => ({ text: a.textContent.trim(), href: a.href }))
286
+ .filter(l => l.text.length > 0 && l.href.startsWith('http'))
287
+ .slice(0, 30);
288
+ const bodyText = document.body?.innerText?.substring(0, 5000) || '';
289
+
290
+ return { title, metaDescription, headings, paragraphs, links, bodyText, url: location.href };
291
+ });
292
+ }
293
+
294
+ async evaluate(fn, ...args) {
295
+ this._ensureConnected();
296
+ return this.page.evaluate(fn, ...args);
297
+ }
298
+
299
+ async waitFor(selector, timeout) {
300
+ this._ensureConnected();
301
+ await this.page.waitForSelector(selector, { timeout: timeout || this.timeout });
302
+ return { success: true };
303
+ }
304
+
305
+ // ── Screenshot ──────────────────────────────────────────────
306
+
307
+ async screenshot(options = {}) {
308
+ this._ensureConnected();
309
+
310
+ // LightPanda can't screenshot — auto-fallback
311
+ if (this.activeBackend === 'lightpanda') {
312
+ if (this.mode === 'headless') {
313
+ throw new Error('screenshot() not available in headless-only mode (LightPanda has no rendering engine)');
314
+ }
315
+ await this._fallbackToChrome(FALLBACK_REASONS.SCREENSHOT);
316
+ }
317
+
318
+ // type_ avoids collision with executeAction's { type: 'screenshot' }
319
+ const imageFormat = options.type_ || (options.type !== 'screenshot' && options.type) || 'png';
320
+ const buffer = await this.page.screenshot({
321
+ type: imageFormat,
322
+ fullPage: options.fullPage ?? true,
323
+ encoding: 'base64',
324
+ });
325
+
326
+ this._logAction('screenshot');
327
+ return {
328
+ success: true,
329
+ screenshot: `data:image/${imageFormat};base64,${buffer}`,
330
+ backend: this.activeBackend,
331
+ };
332
+ }
333
+
334
+ // ── AI Agent Interface ──────────────────────────────────────
335
+
336
+ /**
337
+ * Returns structured page state for AI decision-making.
338
+ * Includes interactive elements with selectors for the AI to use.
339
+ */
340
+ async getPageState(options = {}) {
341
+ this._ensureConnected();
342
+
343
+ const state = await this.page.evaluate(() => {
344
+ const interactiveElements = [];
345
+
346
+ // Buttons
347
+ document.querySelectorAll('button, [role="button"], input[type="submit"], input[type="button"]').forEach((el, i) => {
348
+ if (el.offsetParent === null) return; // hidden
349
+ const text = el.textContent?.trim() || el.value || el.getAttribute('aria-label') || '';
350
+ if (!text) return;
351
+ interactiveElements.push({
352
+ type: 'button',
353
+ text: text.substring(0, 100),
354
+ selector: el.id ? `#${el.id}` : `button:nth-of-type(${i + 1})`,
355
+ tag: el.tagName.toLowerCase(),
356
+ });
357
+ });
358
+
359
+ // Links
360
+ document.querySelectorAll('a[href]').forEach((el) => {
361
+ if (el.offsetParent === null) return;
362
+ const text = el.textContent?.trim();
363
+ if (!text || text.length < 2) return;
364
+ interactiveElements.push({
365
+ type: 'link',
366
+ text: text.substring(0, 100),
367
+ href: el.href,
368
+ selector: el.id ? `#${el.id}` : `a[href="${el.getAttribute('href')}"]`,
369
+ });
370
+ });
371
+
372
+ // Inputs
373
+ document.querySelectorAll('input, textarea, select').forEach((el) => {
374
+ if (el.offsetParent === null || el.type === 'hidden') return;
375
+ const label = el.getAttribute('aria-label')
376
+ || el.placeholder
377
+ || document.querySelector(`label[for="${el.id}"]`)?.textContent?.trim()
378
+ || el.name
379
+ || '';
380
+ interactiveElements.push({
381
+ type: el.tagName.toLowerCase() === 'select' ? 'select' : 'input',
382
+ inputType: el.type || 'text',
383
+ label: label.substring(0, 100),
384
+ value: el.value?.substring(0, 50) || '',
385
+ selector: el.id ? `#${el.id}` : `[name="${el.name}"]`,
386
+ tag: el.tagName.toLowerCase(),
387
+ });
388
+ });
389
+
390
+ return {
391
+ url: location.href,
392
+ title: document.title,
393
+ text: document.body?.innerText?.substring(0, 3000) || '',
394
+ interactiveElements: interactiveElements.slice(0, 50),
395
+ };
396
+ });
397
+
398
+ // Optionally include screenshot
399
+ if (options.includeScreenshot && this.activeBackend !== 'lightpanda') {
400
+ const { screenshot } = await this.screenshot();
401
+ state.screenshot = screenshot;
402
+ } else if (options.includeScreenshot && this.mode === 'auto') {
403
+ await this._fallbackToChrome(FALLBACK_REASONS.SCREENSHOT);
404
+ const { screenshot } = await this.screenshot();
405
+ state.screenshot = screenshot;
406
+ }
407
+
408
+ state.backend = this.activeBackend;
409
+ state.sessionHistory = this.history.slice(-10);
410
+
411
+ return state;
412
+ }
413
+
414
+ /**
415
+ * Execute a structured action from an AI agent.
416
+ * @param {{ type: string, selector?: string, text?: string, url?: string, key?: string, direction?: string, amount?: number }} action
417
+ */
418
+ async executeAction(action) {
419
+ switch (action.type) {
420
+ case 'goto': return this.goto(action.url);
421
+ case 'click': return this.click(action.selector, action);
422
+ case 'type': return this.type(action.selector, action.text, action);
423
+ case 'scroll': return this.scroll(action.direction, action.amount);
424
+ case 'hover': return this.hover(action.selector);
425
+ case 'select': return this.select(action.selector, ...(action.values || []));
426
+ case 'pressKey': return this.pressKey(action.key);
427
+ case 'goBack': return this.goBack();
428
+ case 'goForward': return this.goForward();
429
+ case 'screenshot': return this.screenshot(action);
430
+ case 'extractContent': return this.extractContent();
431
+ case 'waitFor': return this.waitFor(action.selector, action.timeout);
432
+ // Coordinate-based actions (computer-use mode)
433
+ case 'mouseMove': return this.mouseMove(action.x, action.y);
434
+ case 'clickAt': return this.clickAt(action.x, action.y, action.button);
435
+ case 'doubleClickAt': return this.doubleClickAt(action.x, action.y, action.button);
436
+ case 'drag': return this.drag(action.startX, action.startY, action.endX, action.endY);
437
+ case 'scrollAt': return this.scrollAt(action.x, action.y, action.direction, action.amount);
438
+ case 'typeText': return this.typeText(action.text);
439
+ case 'getCursorPosition': return this.getCursorPosition();
440
+ case 'getScreenSize': return this.getScreenSize();
441
+ default: throw new Error(`Unknown action type: ${action.type}`);
442
+ }
443
+ }
444
+
445
+ // ── Cookies ─────────────────────────────────────────────────
446
+
447
+ async getCookies() {
448
+ this._ensureConnected();
449
+ return this.page.cookies();
450
+ }
451
+
452
+ async setCookies(cookies) {
453
+ this._ensureConnected();
454
+ if (this.activeBackend === 'lightpanda') {
455
+ // LP doesn't support Network.deleteCookies which Puppeteer's setCookie calls.
456
+ // Use the page's internal CDP session to call Network.setCookies directly.
457
+ try {
458
+ const client = this.page._client();
459
+ await client.send('Network.setCookies', { cookies });
460
+ } catch {
461
+ // Fallback: set cookies via document.cookie (limited to non-httpOnly)
462
+ for (const c of cookies) {
463
+ const parts = [`${c.name}=${c.value}`];
464
+ if (c.domain) parts.push(`domain=${c.domain}`);
465
+ if (c.path) parts.push(`path=${c.path}`);
466
+ await this.page.evaluate((cookieStr) => { document.cookie = cookieStr; }, parts.join('; '));
467
+ }
468
+ }
469
+ } else {
470
+ await this.page.setCookie(...cookies);
471
+ }
472
+ }
473
+
474
+ // ── Coordinate-based actions (computer-use mode) ────────────
475
+
476
+ _ensureProvider() {
477
+ if (!this.provider || this.activeBackend !== 'computer-use') {
478
+ throw new Error('Coordinate-based actions require computer-use mode with a provider');
479
+ }
480
+ }
481
+
482
+ async mouseMove(x, y) {
483
+ this._ensureConnected();
484
+ this._ensureProvider();
485
+ const result = await this.provider.mouseMove(x, y);
486
+ this._logAction('mouseMove', { x, y });
487
+ return result;
488
+ }
489
+
490
+ async clickAt(x, y, button = 'left') {
491
+ this._ensureConnected();
492
+ this._ensureProvider();
493
+ const result = await this.provider.mouseClick(x, y, button);
494
+ this._logAction('clickAt', { x, y, button });
495
+ return result;
496
+ }
497
+
498
+ async doubleClickAt(x, y, button = 'left') {
499
+ this._ensureConnected();
500
+ this._ensureProvider();
501
+ const result = await this.provider.mouseDoubleClick(x, y, button);
502
+ this._logAction('doubleClickAt', { x, y, button });
503
+ return result;
504
+ }
505
+
506
+ async drag(startX, startY, endX, endY) {
507
+ this._ensureConnected();
508
+ this._ensureProvider();
509
+ const result = await this.provider.mouseDrag(startX, startY, endX, endY);
510
+ this._logAction('drag', { startX, startY, endX, endY });
511
+ return result;
512
+ }
513
+
514
+ async scrollAt(x, y, direction = 'down', amount = 3) {
515
+ this._ensureConnected();
516
+ this._ensureProvider();
517
+ const result = await this.provider.scroll(x, y, direction, amount);
518
+ this._logAction('scrollAt', { x, y, direction, amount });
519
+ return result;
520
+ }
521
+
522
+ async typeText(text) {
523
+ this._ensureConnected();
524
+ this._ensureProvider();
525
+ const result = await this.provider.typeText(text);
526
+ this._logAction('typeText', { text: text.substring(0, 20) + (text.length > 20 ? '...' : '') });
527
+ return result;
528
+ }
529
+
530
+ async getCursorPosition() {
531
+ this._ensureConnected();
532
+ this._ensureProvider();
533
+ return this.provider.getCursorPosition();
534
+ }
535
+
536
+ async getScreenSize() {
537
+ this._ensureConnected();
538
+ this._ensureProvider();
539
+ return this.provider.getScreenSize();
540
+ }
541
+
542
+ getVncUrl() {
543
+ return this._providerInfo?.vncUrl || null;
544
+ }
545
+
546
+ // ── Fallback ────────────────────────────────────────────────
547
+
548
+ async _fallbackToChrome(reason) {
549
+ if (this.activeBackend === 'chrome') return; // already on Chrome
550
+ if (this._fallbackCount > 2) throw new Error('Too many fallback attempts');
551
+
552
+ this._log(`Falling back to Chrome: ${reason}`);
553
+ this._fallbackCount++;
554
+
555
+ // Save state from LP session
556
+ let cookies = [];
557
+ let currentUrl = null;
558
+ try {
559
+ cookies = await this.page.cookies();
560
+ currentUrl = this.page.url();
561
+ } catch {
562
+ // LP might be in a bad state
563
+ }
564
+
565
+ // Close LP page (keep server alive for potential reuse)
566
+ try {
567
+ if (this.page && !this.page.isClosed()) await this.page.close();
568
+ if (this.context) await this.context.close();
569
+ if (this.browser) await this.browser.disconnect();
570
+ } catch {
571
+ // ignore cleanup errors
572
+ }
573
+
574
+ // Connect to Chrome
575
+ await this._connectChrome();
576
+
577
+ // Restore state
578
+ if (cookies.length > 0) {
579
+ await this.page.setCookie(...cookies);
580
+ }
581
+ if (currentUrl && currentUrl !== 'about:blank') {
582
+ await this.page.goto(currentUrl, {
583
+ waitUntil: 'networkidle0',
584
+ timeout: this.timeout,
585
+ });
586
+ }
587
+
588
+ this._logAction('fallback', { reason, from: 'lightpanda', to: 'chrome' });
589
+ }
590
+
591
+ async _shouldFallback(error) {
592
+ if (this.activeBackend === 'chrome') return false;
593
+ if (this.mode === 'headless') return false; // no auto-fallback in explicit headless mode
594
+
595
+ const msg = error.message || '';
596
+ return (
597
+ msg.includes('Protocol error') ||
598
+ msg.includes('not implemented') ||
599
+ msg.includes('Target closed') ||
600
+ msg.includes('Session closed') ||
601
+ msg.includes('Connection closed') ||
602
+ msg.includes('Execution context was destroyed')
603
+ );
604
+ }
605
+
606
+ // ── Cleanup ─────────────────────────────────────────────────
607
+
608
+ async close() {
609
+ try {
610
+ if (this.page && !this.page.isClosed()) await this.page.close();
611
+ } catch { /* ignore */ }
612
+
613
+ try {
614
+ if (this.context) await this.context.close();
615
+ } catch { /* ignore */ }
616
+
617
+ if (this.activeBackend === 'computer-use') {
618
+ // Provider mode: disconnect from CDP, then stop the provider
619
+ try { if (this.browser) await this.browser.disconnect(); } catch { /* ignore */ }
620
+ try { if (this.provider) await this.provider.stop(); } catch { /* ignore */ }
621
+ } else if (this.activeBackend === 'lightpanda' && this.browser) {
622
+ try { await this.browser.disconnect(); } catch { /* ignore */ }
623
+ } else if (this.mode === 'visual' && this.browser && !this._chromeBrowser) {
624
+ // Visual mode: launched directly, not from pool — close the browser process
625
+ try { await this.browser.close(); } catch { /* ignore */ }
626
+ }
627
+
628
+ if (this._chromeBrowser) {
629
+ browserPool.releaseBrowser(this._chromeBrowser);
630
+ this._chromeBrowser = null;
631
+ }
632
+
633
+ this.page = null;
634
+ this.context = null;
635
+ this.browser = null;
636
+ this._connected = false;
637
+ this._log('Session closed');
638
+ }
639
+
640
+ // ── Helpers ─────────────────────────────────────────────────
641
+
642
+ _ensureConnected() {
643
+ if (!this._connected || !this.page) {
644
+ throw new Error('Session not connected. Call connect() first.');
645
+ }
646
+ }
647
+
648
+ async _getPuppeteer() {
649
+ try {
650
+ const puppeteer = await import('puppeteer');
651
+ return puppeteer.default || puppeteer;
652
+ } catch {
653
+ throw new Error('Puppeteer is required for BrowserSession. Install with: npm install puppeteer');
654
+ }
655
+ }
656
+
657
+ _log(msg) {
658
+ if (this.verbose) console.log(`[BrowserSession] ${msg}`);
659
+ }
660
+
661
+ _logAction(type, params = {}) {
662
+ const entry = { type, ...params, timestamp: Date.now(), backend: this.activeBackend };
663
+ this.history.push(entry);
664
+ this._log(`${type} ${JSON.stringify(params)}`);
665
+ }
666
+
667
+ getHistory() {
668
+ return this.history;
669
+ }
670
+
671
+ getBackend() {
672
+ return this.activeBackend;
673
+ }
674
+ }
675
+
676
+ /**
677
+ * Convenience function to create and connect a browser session.
678
+ */
679
+ export async function createSession(options = {}) {
680
+ const session = new BrowserSession(options);
681
+ await session.connect();
682
+ return session;
683
+ }
684
+
685
+ export default BrowserSession;