@askjo/camofox-browser 1.0.12

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,1288 @@
1
+ require('dotenv').config();
2
+ const { Camoufox, launchOptions } = require('camoufox-js');
3
+ const { firefox } = require('playwright-core');
4
+ const express = require('express');
5
+ const crypto = require('crypto');
6
+ const os = require('os');
7
+ const { expandMacro } = require('./lib/macros');
8
+
9
+ const app = express();
10
+ app.use(express.json({ limit: '5mb' }));
11
+
12
+ let browser = null;
13
+ // userId -> { context, tabGroups: Map<sessionKey, Map<tabId, TabState>>, lastAccess }
14
+ // TabState = { page, refs: Map<refId, {role, name, nth}>, visitedUrls: Set, toolCalls: number }
15
+ // Note: sessionKey was previously called listItemId - both are accepted for backward compatibility
16
+ const sessions = new Map();
17
+
18
+ const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 min
19
+ const MAX_SNAPSHOT_NODES = 500;
20
+ const DEBUG_RESPONSES = true; // Log response payloads
21
+
22
+ function logResponse(endpoint, data) {
23
+ if (!DEBUG_RESPONSES) return;
24
+ let logData = data;
25
+ // Truncate snapshot for readability
26
+ if (data && data.snapshot) {
27
+ const snap = data.snapshot;
28
+ logData = { ...data, snapshot: `[${snap.length} chars] ${snap.slice(0, 300)}...` };
29
+ }
30
+ console.log(`📤 ${endpoint} ->`, JSON.stringify(logData, null, 2));
31
+ }
32
+
33
+ // Per-tab locks to serialize operations on the same tab
34
+ // tabId -> Promise (the currently executing operation)
35
+ const tabLocks = new Map();
36
+
37
+ async function withTabLock(tabId, operation) {
38
+ // Wait for any pending operation on this tab to complete
39
+ const pending = tabLocks.get(tabId);
40
+ if (pending) {
41
+ try {
42
+ await pending;
43
+ } catch (e) {
44
+ // Previous operation failed, continue anyway
45
+ }
46
+ }
47
+
48
+ // Execute this operation and store the promise
49
+ const promise = operation();
50
+ tabLocks.set(tabId, promise);
51
+
52
+ try {
53
+ return await promise;
54
+ } finally {
55
+ // Clean up if this is still the active lock
56
+ if (tabLocks.get(tabId) === promise) {
57
+ tabLocks.delete(tabId);
58
+ }
59
+ }
60
+ }
61
+
62
+ // Detect host OS for fingerprint generation
63
+ function getHostOS() {
64
+ const platform = os.platform();
65
+ if (platform === 'darwin') return 'macos';
66
+ if (platform === 'win32') return 'windows';
67
+ return 'linux';
68
+ }
69
+
70
+ async function ensureBrowser() {
71
+ if (!browser) {
72
+ const hostOS = getHostOS();
73
+ console.log(`Launching Camoufox browser (host OS: ${hostOS})...`);
74
+
75
+ const options = await launchOptions({
76
+ headless: true,
77
+ os: hostOS,
78
+ humanize: true,
79
+ enable_cache: true,
80
+ });
81
+
82
+ browser = await firefox.launch(options);
83
+ console.log('Camoufox browser launched');
84
+ }
85
+ return browser;
86
+ }
87
+
88
+ // Helper to normalize userId to string (JSON body may parse as number)
89
+ function normalizeUserId(userId) {
90
+ return String(userId);
91
+ }
92
+
93
+ async function getSession(userId) {
94
+ const key = normalizeUserId(userId);
95
+ let session = sessions.get(key);
96
+ if (!session) {
97
+ const b = await ensureBrowser();
98
+ const context = await b.newContext({
99
+ viewport: { width: 1280, height: 720 },
100
+ locale: 'en-US',
101
+ timezoneId: 'America/Los_Angeles',
102
+ geolocation: { latitude: 37.7749, longitude: -122.4194 },
103
+ permissions: ['geolocation'],
104
+ });
105
+
106
+ session = { context, tabGroups: new Map(), lastAccess: Date.now() };
107
+ sessions.set(key, session);
108
+ console.log(`Session created for user ${key}`);
109
+ }
110
+ session.lastAccess = Date.now();
111
+ return session;
112
+ }
113
+
114
+ function getTabGroup(session, listItemId) {
115
+ let group = session.tabGroups.get(listItemId);
116
+ if (!group) {
117
+ group = new Map();
118
+ session.tabGroups.set(listItemId, group);
119
+ }
120
+ return group;
121
+ }
122
+
123
+ function findTab(session, tabId) {
124
+ for (const [listItemId, group] of session.tabGroups) {
125
+ if (group.has(tabId)) {
126
+ const tabState = group.get(tabId);
127
+ return { tabState, listItemId, group };
128
+ }
129
+ }
130
+ return null;
131
+ }
132
+
133
+ function createTabState(page) {
134
+ return {
135
+ page,
136
+ refs: new Map(),
137
+ visitedUrls: new Set(),
138
+ toolCalls: 0
139
+ };
140
+ }
141
+
142
+ async function waitForPageReady(page, options = {}) {
143
+ const { timeout = 10000, waitForNetwork = true } = options;
144
+
145
+ try {
146
+ await page.waitForLoadState('domcontentloaded', { timeout });
147
+
148
+ if (waitForNetwork) {
149
+ await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {
150
+ console.log('waitForPageReady: networkidle timeout (continuing anyway)');
151
+ });
152
+ }
153
+
154
+ // Framework hydration wait (React/Next.js/Vue) - mirrors Swift WebView.swift logic
155
+ // Wait for readyState === 'complete' + network quiet (40 iterations × 250ms max)
156
+ await page.evaluate(async () => {
157
+ for (let i = 0; i < 40; i++) {
158
+ // Check if network is quiet (no recent resource loads)
159
+ const entries = performance.getEntriesByType('resource');
160
+ const recentEntries = entries.slice(-5);
161
+ const netQuiet = recentEntries.every(e => (performance.now() - e.responseEnd) > 400);
162
+
163
+ if (document.readyState === 'complete' && netQuiet) {
164
+ // Double RAF to ensure paint is complete
165
+ await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
166
+ break;
167
+ }
168
+ await new Promise(r => setTimeout(r, 250));
169
+ }
170
+ }).catch(() => {
171
+ console.log('waitForPageReady: framework hydration wait failed (continuing anyway)');
172
+ });
173
+
174
+ await page.waitForTimeout(200);
175
+
176
+ // Auto-dismiss common consent/privacy dialogs
177
+ await dismissConsentDialogs(page);
178
+
179
+ return true;
180
+ } catch (err) {
181
+ console.log(`waitForPageReady: ${err.message}`);
182
+ return false;
183
+ }
184
+ }
185
+
186
+ async function dismissConsentDialogs(page) {
187
+ // Common consent/privacy dialog selectors (matches Swift WebView.swift patterns)
188
+ const dismissSelectors = [
189
+ // OneTrust (very common)
190
+ '#onetrust-banner-sdk button#onetrust-accept-btn-handler',
191
+ '#onetrust-banner-sdk button#onetrust-reject-all-handler',
192
+ '#onetrust-close-btn-container button',
193
+ // Generic patterns
194
+ 'button[data-test="cookie-accept-all"]',
195
+ 'button[aria-label="Accept all"]',
196
+ 'button[aria-label="Accept All"]',
197
+ 'button[aria-label="Close"]',
198
+ 'button[aria-label="Dismiss"]',
199
+ // Dialog close buttons
200
+ 'dialog button:has-text("Close")',
201
+ 'dialog button:has-text("Accept")',
202
+ 'dialog button:has-text("I Accept")',
203
+ 'dialog button:has-text("Got it")',
204
+ 'dialog button:has-text("OK")',
205
+ // GDPR/CCPA specific
206
+ '[class*="consent"] button[class*="accept"]',
207
+ '[class*="consent"] button[class*="close"]',
208
+ '[class*="privacy"] button[class*="close"]',
209
+ '[class*="cookie"] button[class*="accept"]',
210
+ '[class*="cookie"] button[class*="close"]',
211
+ // Overlay close buttons
212
+ '[class*="modal"] button[class*="close"]',
213
+ '[class*="overlay"] button[class*="close"]',
214
+ ];
215
+
216
+ for (const selector of dismissSelectors) {
217
+ try {
218
+ const button = page.locator(selector).first();
219
+ if (await button.isVisible({ timeout: 100 })) {
220
+ await button.click({ timeout: 1000 }).catch(() => {});
221
+ console.log(`🍪 Auto-dismissed consent dialog via: ${selector}`);
222
+ await page.waitForTimeout(300); // Brief pause after dismiss
223
+ break; // Only dismiss one dialog per page load
224
+ }
225
+ } catch (e) {
226
+ // Selector not found or not clickable, continue
227
+ }
228
+ }
229
+ }
230
+
231
+ async function buildRefs(page) {
232
+ const refs = new Map();
233
+
234
+ if (!page || page.isClosed()) {
235
+ console.log('buildRefs: Page is closed or invalid');
236
+ return refs;
237
+ }
238
+
239
+ await waitForPageReady(page, { waitForNetwork: false });
240
+
241
+ // Get ARIA snapshot including shadow DOM content
242
+ // Playwright's ariaSnapshot already traverses shadow roots, but we also
243
+ // inject a script to collect shadow DOM elements for additional coverage
244
+ let ariaYaml;
245
+ try {
246
+ ariaYaml = await page.locator('body').ariaSnapshot({ timeout: 10000 });
247
+ } catch (err) {
248
+ console.log('buildRefs: ariaSnapshot failed, retrying after navigation settles');
249
+ await page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
250
+ ariaYaml = await page.locator('body').ariaSnapshot({ timeout: 10000 });
251
+ }
252
+
253
+ // Collect additional interactive elements from shadow DOM
254
+ const shadowElements = await page.evaluate(() => {
255
+ const elements = [];
256
+ const collectFromShadow = (root, depth = 0) => {
257
+ if (depth > 5) return; // Limit recursion
258
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
259
+ while (walker.nextNode()) {
260
+ const el = walker.currentNode;
261
+ if (el.shadowRoot) {
262
+ collectFromShadow(el.shadowRoot, depth + 1);
263
+ }
264
+ }
265
+ };
266
+ // Start collection from all shadow roots
267
+ document.querySelectorAll('*').forEach(el => {
268
+ if (el.shadowRoot) collectFromShadow(el.shadowRoot);
269
+ });
270
+ return elements;
271
+ }).catch(() => []);
272
+
273
+ if (!ariaYaml) {
274
+ console.log('buildRefs: No aria snapshot available');
275
+ return refs;
276
+ }
277
+
278
+ const lines = ariaYaml.split('\n');
279
+ let refCounter = 1;
280
+
281
+ // Interactive roles to include - exclude combobox to avoid opening complex widgets
282
+ // (date pickers, dropdowns) that can interfere with navigation
283
+ const interactiveRoles = [
284
+ 'button', 'link', 'textbox', 'checkbox', 'radio',
285
+ 'menuitem', 'tab', 'searchbox', 'slider', 'spinbutton', 'switch'
286
+ // 'combobox' excluded - can trigger date pickers and complex dropdowns
287
+ ];
288
+
289
+ // Patterns to skip (date pickers, calendar widgets)
290
+ const skipPatterns = [
291
+ /date/i, /calendar/i, /picker/i, /datepicker/i
292
+ ];
293
+
294
+ // Track occurrences of each role+name combo for nth disambiguation
295
+ const seenCounts = new Map(); // "role:name" -> count
296
+
297
+ for (const line of lines) {
298
+ if (refCounter > MAX_SNAPSHOT_NODES) break;
299
+
300
+ const match = line.match(/^\s*-\s+(\w+)(?:\s+"([^"]*)")?/);
301
+ if (match) {
302
+ const [, role, name] = match;
303
+ const normalizedRole = role.toLowerCase();
304
+
305
+ // Skip combobox role entirely (date pickers, complex dropdowns)
306
+ if (normalizedRole === 'combobox') continue;
307
+
308
+ // Skip elements with date/calendar-related names
309
+ if (name && skipPatterns.some(p => p.test(name))) continue;
310
+
311
+ if (interactiveRoles.includes(normalizedRole)) {
312
+ const normalizedName = name || '';
313
+ const key = `${normalizedRole}:${normalizedName}`;
314
+
315
+ // Get current count and increment
316
+ const nth = seenCounts.get(key) || 0;
317
+ seenCounts.set(key, nth + 1);
318
+
319
+ const refId = `e${refCounter++}`;
320
+ refs.set(refId, { role: normalizedRole, name: normalizedName, nth });
321
+ }
322
+ }
323
+ }
324
+
325
+ return refs;
326
+ }
327
+
328
+ async function getAriaSnapshot(page) {
329
+ if (!page || page.isClosed()) {
330
+ return null;
331
+ }
332
+ await waitForPageReady(page, { waitForNetwork: false });
333
+ return await page.locator('body').ariaSnapshot({ timeout: 10000 });
334
+ }
335
+
336
+ function refToLocator(page, ref, refs) {
337
+ const info = refs.get(ref);
338
+ if (!info) return null;
339
+
340
+ const { role, name, nth } = info;
341
+ let locator = page.getByRole(role, name ? { name } : undefined);
342
+
343
+ // Always use .nth() to disambiguate duplicate role+name combinations
344
+ // This avoids "strict mode violation" when multiple elements match
345
+ locator = locator.nth(nth);
346
+
347
+ return locator;
348
+ }
349
+
350
+ // Health check
351
+ app.get('/health', async (req, res) => {
352
+ try {
353
+ const b = await ensureBrowser();
354
+ res.json({
355
+ ok: true,
356
+ engine: 'camoufox',
357
+ sessions: sessions.size,
358
+ browserConnected: b.isConnected()
359
+ });
360
+ } catch (err) {
361
+ res.status(500).json({ ok: false, error: err.message });
362
+ }
363
+ });
364
+
365
+ // Create new tab
366
+ app.post('/tabs', async (req, res) => {
367
+ try {
368
+ const { userId, sessionKey, listItemId, url } = req.body;
369
+ // Accept both sessionKey (preferred) and listItemId (legacy) for backward compatibility
370
+ const resolvedSessionKey = sessionKey || listItemId;
371
+ if (!userId || !resolvedSessionKey) {
372
+ return res.status(400).json({ error: 'userId and sessionKey required' });
373
+ }
374
+
375
+ const session = await getSession(userId);
376
+ const group = getTabGroup(session, resolvedSessionKey);
377
+
378
+ const page = await session.context.newPage();
379
+ const tabId = crypto.randomUUID();
380
+ const tabState = createTabState(page);
381
+ group.set(tabId, tabState);
382
+
383
+ if (url) {
384
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
385
+ tabState.visitedUrls.add(url);
386
+ }
387
+
388
+ console.log(`Tab ${tabId} created for user ${userId}, session ${resolvedSessionKey}`);
389
+ res.json({ tabId, url: page.url() });
390
+ } catch (err) {
391
+ console.error('Create tab error:', err);
392
+ res.status(500).json({ error: err.message });
393
+ }
394
+ });
395
+
396
+ // Navigate
397
+ app.post('/tabs/:tabId/navigate', async (req, res) => {
398
+ const tabId = req.params.tabId;
399
+
400
+ try {
401
+ const { userId, url, macro, query } = req.body;
402
+ const session = sessions.get(normalizeUserId(userId));
403
+ const found = session && findTab(session, tabId);
404
+ if (!found) return res.status(404).json({ error: 'Tab not found' });
405
+
406
+ const { tabState } = found;
407
+ tabState.toolCalls++;
408
+
409
+ let targetUrl = url;
410
+ if (macro) {
411
+ targetUrl = expandMacro(macro, query) || url;
412
+ }
413
+
414
+ if (!targetUrl) {
415
+ return res.status(400).json({ error: 'url or macro required' });
416
+ }
417
+
418
+ // Serialize navigation operations on the same tab
419
+ const result = await withTabLock(tabId, async () => {
420
+ await tabState.page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
421
+ tabState.visitedUrls.add(targetUrl);
422
+ tabState.refs = await buildRefs(tabState.page);
423
+ return { ok: true, url: tabState.page.url() };
424
+ });
425
+
426
+ logResponse(`POST /tabs/${tabId}/navigate`, result);
427
+ res.json(result);
428
+ } catch (err) {
429
+ console.error('Navigate error:', err);
430
+ res.status(500).json({ error: err.message });
431
+ }
432
+ });
433
+
434
+ // Snapshot
435
+ app.get('/tabs/:tabId/snapshot', async (req, res) => {
436
+ try {
437
+ const userId = req.query.userId;
438
+ const format = req.query.format || 'text';
439
+ const session = sessions.get(normalizeUserId(userId));
440
+ const found = session && findTab(session, req.params.tabId);
441
+ if (!found) return res.status(404).json({ error: 'Tab not found' });
442
+
443
+ const { tabState } = found;
444
+ tabState.toolCalls++;
445
+ tabState.refs = await buildRefs(tabState.page);
446
+
447
+ const ariaYaml = await getAriaSnapshot(tabState.page);
448
+
449
+ // Annotate YAML with ref IDs for interactive elements
450
+ let annotatedYaml = ariaYaml || '';
451
+ if (annotatedYaml && tabState.refs.size > 0) {
452
+ // Build a map of role+name -> refId for annotation
453
+ const refsByKey = new Map();
454
+ const seenCounts = new Map();
455
+ for (const [refId, info] of tabState.refs) {
456
+ const key = `${info.role}:${info.name}:${info.nth}`;
457
+ refsByKey.set(key, refId);
458
+ }
459
+
460
+ // Track occurrences while annotating
461
+ const annotationCounts = new Map();
462
+ const lines = annotatedYaml.split('\n');
463
+ // Must match buildRefs - excludes combobox to avoid date pickers/complex dropdowns
464
+ const interactiveRoles = [
465
+ 'button', 'link', 'textbox', 'checkbox', 'radio',
466
+ 'menuitem', 'tab', 'searchbox', 'slider', 'spinbutton', 'switch'
467
+ ];
468
+ const skipPatterns = [/date/i, /calendar/i, /picker/i, /datepicker/i];
469
+
470
+ annotatedYaml = lines.map(line => {
471
+ const match = line.match(/^(\s*-\s+)(\w+)(\s+"([^"]*)")?(.*)$/);
472
+ if (match) {
473
+ const [, prefix, role, nameMatch, name, suffix] = match;
474
+ const normalizedRole = role.toLowerCase();
475
+
476
+ // Skip combobox and date-related elements (same as buildRefs)
477
+ if (normalizedRole === 'combobox') return line;
478
+ if (name && skipPatterns.some(p => p.test(name))) return line;
479
+
480
+ if (interactiveRoles.includes(normalizedRole)) {
481
+ const normalizedName = name || '';
482
+ const countKey = `${normalizedRole}:${normalizedName}`;
483
+ const nth = annotationCounts.get(countKey) || 0;
484
+ annotationCounts.set(countKey, nth + 1);
485
+
486
+ const key = `${normalizedRole}:${normalizedName}:${nth}`;
487
+ const refId = refsByKey.get(key);
488
+ if (refId) {
489
+ return `${prefix}${role}${nameMatch || ''} [${refId}]${suffix}`;
490
+ }
491
+ }
492
+ }
493
+ return line;
494
+ }).join('\n');
495
+ }
496
+
497
+ const result = {
498
+ url: tabState.page.url(),
499
+ snapshot: annotatedYaml,
500
+ refsCount: tabState.refs.size
501
+ };
502
+ logResponse(`GET /tabs/${req.params.tabId}/snapshot`, result);
503
+ res.json(result);
504
+ } catch (err) {
505
+ console.error('Snapshot error:', err);
506
+ res.status(500).json({ error: err.message });
507
+ }
508
+ });
509
+
510
+ // Wait for page ready
511
+ app.post('/tabs/:tabId/wait', async (req, res) => {
512
+ try {
513
+ const { userId, timeout = 10000, waitForNetwork = true } = req.body;
514
+ const session = sessions.get(normalizeUserId(userId));
515
+ const found = session && findTab(session, req.params.tabId);
516
+ if (!found) return res.status(404).json({ error: 'Tab not found' });
517
+
518
+ const { tabState } = found;
519
+ const ready = await waitForPageReady(tabState.page, { timeout, waitForNetwork });
520
+
521
+ res.json({ ok: true, ready });
522
+ } catch (err) {
523
+ console.error('Wait error:', err);
524
+ res.status(500).json({ error: err.message });
525
+ }
526
+ });
527
+
528
+ // Click
529
+ app.post('/tabs/:tabId/click', async (req, res) => {
530
+ const tabId = req.params.tabId;
531
+
532
+ try {
533
+ const { userId, ref, selector } = req.body;
534
+ const session = sessions.get(normalizeUserId(userId));
535
+ const found = session && findTab(session, tabId);
536
+ if (!found) return res.status(404).json({ error: 'Tab not found' });
537
+
538
+ const { tabState } = found;
539
+ tabState.toolCalls++;
540
+
541
+ if (!ref && !selector) {
542
+ return res.status(400).json({ error: 'ref or selector required' });
543
+ }
544
+
545
+ const result = await withTabLock(tabId, async () => {
546
+ // Full mouse event sequence for stubborn JS click handlers (mirrors Swift WebView.swift)
547
+ // Dispatches: mouseover → mouseenter → mousedown → mouseup → click
548
+ const dispatchMouseSequence = async (locator) => {
549
+ const box = await locator.boundingBox();
550
+ if (!box) throw new Error('Element not visible (no bounding box)');
551
+
552
+ const x = box.x + box.width / 2;
553
+ const y = box.y + box.height / 2;
554
+
555
+ // Move mouse to element (triggers mouseover/mouseenter)
556
+ await tabState.page.mouse.move(x, y);
557
+ await tabState.page.waitForTimeout(50);
558
+
559
+ // Full click sequence
560
+ await tabState.page.mouse.down();
561
+ await tabState.page.waitForTimeout(50);
562
+ await tabState.page.mouse.up();
563
+
564
+ console.log(`🖱️ Dispatched full mouse sequence at (${x.toFixed(0)}, ${y.toFixed(0)})`);
565
+ };
566
+
567
+ const doClick = async (locatorOrSelector, isLocator) => {
568
+ const locator = isLocator ? locatorOrSelector : tabState.page.locator(locatorOrSelector);
569
+
570
+ try {
571
+ // First try normal click (respects visibility, enabled, not-obscured)
572
+ await locator.click({ timeout: 5000 });
573
+ } catch (err) {
574
+ // Fallback 1: If intercepted by overlay, retry with force
575
+ if (err.message.includes('intercepts pointer events')) {
576
+ console.log('Click intercepted, retrying with force:true');
577
+ try {
578
+ await locator.click({ timeout: 5000, force: true });
579
+ } catch (forceErr) {
580
+ // Fallback 2: Full mouse event sequence for stubborn JS handlers
581
+ console.log('Force click failed, trying full mouse sequence');
582
+ await dispatchMouseSequence(locator);
583
+ }
584
+ } else if (err.message.includes('not visible') || err.message.includes('timeout')) {
585
+ // Fallback 2: Element not responding to click, try mouse sequence
586
+ console.log('Click timeout/not visible, trying full mouse sequence');
587
+ await dispatchMouseSequence(locator);
588
+ } else {
589
+ throw err;
590
+ }
591
+ }
592
+ };
593
+
594
+ if (ref) {
595
+ const locator = refToLocator(tabState.page, ref, tabState.refs);
596
+ if (!locator) {
597
+ const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none';
598
+ throw new Error(`Unknown ref: ${ref} (valid refs: e1-${maxRef}, ${tabState.refs.size} total). Refs reset after navigation - call snapshot first.`);
599
+ }
600
+ await doClick(locator, true);
601
+ } else {
602
+ await doClick(selector, false);
603
+ }
604
+
605
+ await tabState.page.waitForTimeout(500);
606
+ tabState.refs = await buildRefs(tabState.page);
607
+
608
+ const newUrl = tabState.page.url();
609
+ tabState.visitedUrls.add(newUrl);
610
+ return { ok: true, url: newUrl };
611
+ });
612
+
613
+ logResponse(`POST /tabs/${tabId}/click`, result);
614
+ res.json(result);
615
+ } catch (err) {
616
+ console.error('Click error:', err);
617
+ res.status(500).json({ error: err.message });
618
+ }
619
+ });
620
+
621
+ // Type
622
+ app.post('/tabs/:tabId/type', async (req, res) => {
623
+ const tabId = req.params.tabId;
624
+
625
+ try {
626
+ const { userId, ref, selector, text } = req.body;
627
+ const session = sessions.get(normalizeUserId(userId));
628
+ const found = session && findTab(session, tabId);
629
+ if (!found) return res.status(404).json({ error: 'Tab not found' });
630
+
631
+ const { tabState } = found;
632
+ tabState.toolCalls++;
633
+
634
+ if (!ref && !selector) {
635
+ return res.status(400).json({ error: 'ref or selector required' });
636
+ }
637
+
638
+ await withTabLock(tabId, async () => {
639
+ if (ref) {
640
+ const locator = refToLocator(tabState.page, ref, tabState.refs);
641
+ if (!locator) throw new Error(`Unknown ref: ${ref}`);
642
+ await locator.fill(text, { timeout: 10000 });
643
+ } else {
644
+ await tabState.page.fill(selector, text, { timeout: 10000 });
645
+ }
646
+ });
647
+
648
+ res.json({ ok: true });
649
+ } catch (err) {
650
+ console.error('Type error:', err);
651
+ res.status(500).json({ error: err.message });
652
+ }
653
+ });
654
+
655
+ // Press key
656
+ app.post('/tabs/:tabId/press', async (req, res) => {
657
+ const tabId = req.params.tabId;
658
+
659
+ try {
660
+ const { userId, key } = req.body;
661
+ const session = sessions.get(normalizeUserId(userId));
662
+ const found = session && findTab(session, tabId);
663
+ if (!found) return res.status(404).json({ error: 'Tab not found' });
664
+
665
+ const { tabState } = found;
666
+ tabState.toolCalls++;
667
+
668
+ await withTabLock(tabId, async () => {
669
+ await tabState.page.keyboard.press(key);
670
+ });
671
+
672
+ res.json({ ok: true });
673
+ } catch (err) {
674
+ console.error('Press error:', err);
675
+ res.status(500).json({ error: err.message });
676
+ }
677
+ });
678
+
679
+ // Scroll
680
+ app.post('/tabs/:tabId/scroll', async (req, res) => {
681
+ try {
682
+ const { userId, direction = 'down', amount = 500 } = req.body;
683
+ const session = sessions.get(normalizeUserId(userId));
684
+ const found = session && findTab(session, req.params.tabId);
685
+ if (!found) return res.status(404).json({ error: 'Tab not found' });
686
+
687
+ const { tabState } = found;
688
+ tabState.toolCalls++;
689
+
690
+ const delta = direction === 'up' ? -amount : amount;
691
+ await tabState.page.mouse.wheel(0, delta);
692
+ await tabState.page.waitForTimeout(300);
693
+
694
+ res.json({ ok: true });
695
+ } catch (err) {
696
+ console.error('Scroll error:', err);
697
+ res.status(500).json({ error: err.message });
698
+ }
699
+ });
700
+
701
+ // Back
702
+ app.post('/tabs/:tabId/back', async (req, res) => {
703
+ const tabId = req.params.tabId;
704
+
705
+ try {
706
+ const { userId } = req.body;
707
+ const session = sessions.get(normalizeUserId(userId));
708
+ const found = session && findTab(session, tabId);
709
+ if (!found) return res.status(404).json({ error: 'Tab not found' });
710
+
711
+ const { tabState } = found;
712
+ tabState.toolCalls++;
713
+
714
+ const result = await withTabLock(tabId, async () => {
715
+ await tabState.page.goBack({ timeout: 10000 });
716
+ tabState.refs = await buildRefs(tabState.page);
717
+ return { ok: true, url: tabState.page.url() };
718
+ });
719
+
720
+ res.json(result);
721
+ } catch (err) {
722
+ console.error('Back error:', err);
723
+ res.status(500).json({ error: err.message });
724
+ }
725
+ });
726
+
727
+ // Forward
728
+ app.post('/tabs/:tabId/forward', async (req, res) => {
729
+ const tabId = req.params.tabId;
730
+
731
+ try {
732
+ const { userId } = req.body;
733
+ const session = sessions.get(normalizeUserId(userId));
734
+ const found = session && findTab(session, tabId);
735
+ if (!found) return res.status(404).json({ error: 'Tab not found' });
736
+
737
+ const { tabState } = found;
738
+ tabState.toolCalls++;
739
+
740
+ const result = await withTabLock(tabId, async () => {
741
+ await tabState.page.goForward({ timeout: 10000 });
742
+ tabState.refs = await buildRefs(tabState.page);
743
+ return { ok: true, url: tabState.page.url() };
744
+ });
745
+
746
+ res.json(result);
747
+ } catch (err) {
748
+ console.error('Forward error:', err);
749
+ res.status(500).json({ error: err.message });
750
+ }
751
+ });
752
+
753
+ // Refresh
754
+ app.post('/tabs/:tabId/refresh', async (req, res) => {
755
+ const tabId = req.params.tabId;
756
+
757
+ try {
758
+ const { userId } = req.body;
759
+ const session = sessions.get(normalizeUserId(userId));
760
+ const found = session && findTab(session, tabId);
761
+ if (!found) return res.status(404).json({ error: 'Tab not found' });
762
+
763
+ const { tabState } = found;
764
+ tabState.toolCalls++;
765
+
766
+ const result = await withTabLock(tabId, async () => {
767
+ await tabState.page.reload({ timeout: 30000 });
768
+ tabState.refs = await buildRefs(tabState.page);
769
+ return { ok: true, url: tabState.page.url() };
770
+ });
771
+
772
+ res.json(result);
773
+ } catch (err) {
774
+ console.error('Refresh error:', err);
775
+ res.status(500).json({ error: err.message });
776
+ }
777
+ });
778
+
779
+ // Get links
780
+ app.get('/tabs/:tabId/links', async (req, res) => {
781
+ try {
782
+ const userId = req.query.userId;
783
+ const limit = parseInt(req.query.limit) || 50;
784
+ const offset = parseInt(req.query.offset) || 0;
785
+ const session = sessions.get(normalizeUserId(userId));
786
+ const found = session && findTab(session, req.params.tabId);
787
+ if (!found) {
788
+ console.log(`GET /tabs/${req.params.tabId}/links -> 404 (userId=${userId}, hasSession=${!!session}, sessionUsers=${[...sessions.keys()].join(',')})`);
789
+ return res.status(404).json({ error: 'Tab not found' });
790
+ }
791
+
792
+ const { tabState } = found;
793
+ tabState.toolCalls++;
794
+
795
+ const allLinks = await tabState.page.evaluate(() => {
796
+ const links = [];
797
+ document.querySelectorAll('a[href]').forEach(a => {
798
+ const href = a.href;
799
+ const text = a.textContent?.trim().slice(0, 100) || '';
800
+ if (href && href.startsWith('http')) {
801
+ links.push({ url: href, text });
802
+ }
803
+ });
804
+ return links;
805
+ });
806
+
807
+ const total = allLinks.length;
808
+ const paginated = allLinks.slice(offset, offset + limit);
809
+
810
+ res.json({
811
+ links: paginated,
812
+ pagination: { total, offset, limit, hasMore: offset + limit < total }
813
+ });
814
+ } catch (err) {
815
+ console.error('Links error:', err);
816
+ res.status(500).json({ error: err.message });
817
+ }
818
+ });
819
+
820
+ // Screenshot
821
+ app.get('/tabs/:tabId/screenshot', async (req, res) => {
822
+ try {
823
+ const userId = req.query.userId;
824
+ const fullPage = req.query.fullPage === 'true';
825
+ const session = sessions.get(normalizeUserId(userId));
826
+ const found = session && findTab(session, req.params.tabId);
827
+ if (!found) return res.status(404).json({ error: 'Tab not found' });
828
+
829
+ const { tabState } = found;
830
+ const buffer = await tabState.page.screenshot({ type: 'png', fullPage });
831
+ res.set('Content-Type', 'image/png');
832
+ res.send(buffer);
833
+ } catch (err) {
834
+ console.error('Screenshot error:', err);
835
+ res.status(500).json({ error: err.message });
836
+ }
837
+ });
838
+
839
+ // Stats
840
+ app.get('/tabs/:tabId/stats', async (req, res) => {
841
+ try {
842
+ const userId = req.query.userId;
843
+ const session = sessions.get(normalizeUserId(userId));
844
+ const found = session && findTab(session, req.params.tabId);
845
+ if (!found) return res.status(404).json({ error: 'Tab not found' });
846
+
847
+ const { tabState, listItemId } = found;
848
+ res.json({
849
+ tabId: req.params.tabId,
850
+ sessionKey: listItemId,
851
+ listItemId, // Legacy compatibility
852
+ url: tabState.page.url(),
853
+ visitedUrls: Array.from(tabState.visitedUrls),
854
+ toolCalls: tabState.toolCalls,
855
+ refsCount: tabState.refs.size
856
+ });
857
+ } catch (err) {
858
+ console.error('Stats error:', err);
859
+ res.status(500).json({ error: err.message });
860
+ }
861
+ });
862
+
863
+ // Close tab
864
+ app.delete('/tabs/:tabId', async (req, res) => {
865
+ try {
866
+ const { userId } = req.body;
867
+ const session = sessions.get(normalizeUserId(userId));
868
+ const found = session && findTab(session, req.params.tabId);
869
+ if (found) {
870
+ await found.tabState.page.close();
871
+ found.group.delete(req.params.tabId);
872
+ if (found.group.size === 0) {
873
+ session.tabGroups.delete(found.listItemId);
874
+ }
875
+ console.log(`Tab ${req.params.tabId} closed for user ${userId}`);
876
+ }
877
+ res.json({ ok: true });
878
+ } catch (err) {
879
+ console.error('Close tab error:', err);
880
+ res.status(500).json({ error: err.message });
881
+ }
882
+ });
883
+
884
+ // Close tab group
885
+ app.delete('/tabs/group/:listItemId', async (req, res) => {
886
+ try {
887
+ const { userId } = req.body;
888
+ const session = sessions.get(normalizeUserId(userId));
889
+ const group = session?.tabGroups.get(req.params.listItemId);
890
+ if (group) {
891
+ for (const [tabId, tabState] of group) {
892
+ await tabState.page.close().catch(() => {});
893
+ }
894
+ session.tabGroups.delete(req.params.listItemId);
895
+ console.log(`Tab group ${req.params.listItemId} closed for user ${userId}`);
896
+ }
897
+ res.json({ ok: true });
898
+ } catch (err) {
899
+ console.error('Close tab group error:', err);
900
+ res.status(500).json({ error: err.message });
901
+ }
902
+ });
903
+
904
+ // Close session
905
+ app.delete('/sessions/:userId', async (req, res) => {
906
+ try {
907
+ const userId = req.params.userId;
908
+ const session = sessions.get(normalizeUserId(userId));
909
+ if (session) {
910
+ await session.context.close();
911
+ sessions.delete(userId);
912
+ console.log(`Session closed for user ${userId}`);
913
+ }
914
+ res.json({ ok: true });
915
+ } catch (err) {
916
+ console.error('Close session error:', err);
917
+ res.status(500).json({ error: err.message });
918
+ }
919
+ });
920
+
921
+ // Cleanup stale sessions
922
+ setInterval(() => {
923
+ const now = Date.now();
924
+ for (const [userId, session] of sessions) {
925
+ if (now - session.lastAccess > SESSION_TIMEOUT_MS) {
926
+ session.context.close().catch(() => {});
927
+ sessions.delete(userId);
928
+ console.log(`Session expired for user ${userId}`);
929
+ }
930
+ }
931
+ }, 60_000);
932
+
933
+ // =============================================================================
934
+ // OpenClaw-compatible endpoint aliases
935
+ // These allow camoufox to be used as a profile backend for OpenClaw's browser tool
936
+ // =============================================================================
937
+
938
+ // GET / - Status (alias for GET /health)
939
+ app.get('/', async (req, res) => {
940
+ try {
941
+ const b = await ensureBrowser();
942
+ res.json({
943
+ ok: true,
944
+ enabled: true,
945
+ running: b.isConnected(),
946
+ engine: 'camoufox',
947
+ sessions: sessions.size,
948
+ browserConnected: b.isConnected()
949
+ });
950
+ } catch (err) {
951
+ res.status(500).json({ ok: false, error: err.message });
952
+ }
953
+ });
954
+
955
+ // GET /tabs - List all tabs (OpenClaw expects this)
956
+ app.get('/tabs', async (req, res) => {
957
+ try {
958
+ const userId = req.query.userId;
959
+ const session = sessions.get(normalizeUserId(userId));
960
+
961
+ if (!session) {
962
+ return res.json({ running: true, tabs: [] });
963
+ }
964
+
965
+ const tabs = [];
966
+ for (const [listItemId, group] of session.tabGroups) {
967
+ for (const [tabId, tabState] of group) {
968
+ tabs.push({
969
+ targetId: tabId,
970
+ tabId,
971
+ url: tabState.page.url(),
972
+ title: await tabState.page.title().catch(() => ''),
973
+ listItemId
974
+ });
975
+ }
976
+ }
977
+
978
+ res.json({ running: true, tabs });
979
+ } catch (err) {
980
+ console.error('List tabs error:', err);
981
+ res.status(500).json({ error: err.message });
982
+ }
983
+ });
984
+
985
+ // POST /tabs/open - Open tab (alias for POST /tabs, OpenClaw format)
986
+ app.post('/tabs/open', async (req, res) => {
987
+ try {
988
+ const { url, userId = 'openclaw', listItemId = 'default' } = req.body;
989
+ if (!url) {
990
+ return res.status(400).json({ error: 'url is required' });
991
+ }
992
+
993
+ const session = await getSession(userId);
994
+ const group = getTabGroup(session, listItemId);
995
+
996
+ const page = await session.context.newPage();
997
+ const tabId = crypto.randomUUID();
998
+ const tabState = createTabState(page);
999
+ group.set(tabId, tabState);
1000
+
1001
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
1002
+ tabState.visitedUrls.add(url);
1003
+
1004
+ console.log(`[OpenClaw] Tab ${tabId} opened: ${url}`);
1005
+ res.json({
1006
+ ok: true,
1007
+ targetId: tabId,
1008
+ tabId,
1009
+ url: page.url(),
1010
+ title: await page.title().catch(() => '')
1011
+ });
1012
+ } catch (err) {
1013
+ console.error('Open tab error:', err);
1014
+ res.status(500).json({ error: err.message });
1015
+ }
1016
+ });
1017
+
1018
+ // POST /start - Start browser (OpenClaw expects this)
1019
+ app.post('/start', async (req, res) => {
1020
+ try {
1021
+ await ensureBrowser();
1022
+ res.json({ ok: true, profile: 'camoufox' });
1023
+ } catch (err) {
1024
+ res.status(500).json({ ok: false, error: err.message });
1025
+ }
1026
+ });
1027
+
1028
+ // POST /stop - Stop browser (OpenClaw expects this)
1029
+ app.post('/stop', async (req, res) => {
1030
+ try {
1031
+ if (browser) {
1032
+ await browser.close().catch(() => {});
1033
+ browser = null;
1034
+ }
1035
+ sessions.clear();
1036
+ res.json({ ok: true, stopped: true, profile: 'camoufox' });
1037
+ } catch (err) {
1038
+ res.status(500).json({ ok: false, error: err.message });
1039
+ }
1040
+ });
1041
+
1042
+ // POST /navigate - Navigate (OpenClaw format with targetId in body)
1043
+ app.post('/navigate', async (req, res) => {
1044
+ try {
1045
+ const { targetId, url, userId = 'openclaw' } = req.body;
1046
+ if (!url) {
1047
+ return res.status(400).json({ error: 'url is required' });
1048
+ }
1049
+
1050
+ const session = sessions.get(normalizeUserId(userId));
1051
+ const found = session && findTab(session, targetId);
1052
+ if (!found) {
1053
+ return res.status(404).json({ error: 'Tab not found' });
1054
+ }
1055
+
1056
+ const { tabState } = found;
1057
+ tabState.toolCalls++;
1058
+
1059
+ const result = await withTabLock(targetId, async () => {
1060
+ await tabState.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
1061
+ tabState.visitedUrls.add(url);
1062
+ tabState.refs = await buildRefs(tabState.page);
1063
+ return { ok: true, targetId, url: tabState.page.url() };
1064
+ });
1065
+
1066
+ res.json(result);
1067
+ } catch (err) {
1068
+ console.error('Navigate error:', err);
1069
+ res.status(500).json({ error: err.message });
1070
+ }
1071
+ });
1072
+
1073
+ // GET /snapshot - Snapshot (OpenClaw format with query params)
1074
+ app.get('/snapshot', async (req, res) => {
1075
+ try {
1076
+ const { targetId, userId = 'openclaw', format = 'text' } = req.query;
1077
+
1078
+ const session = sessions.get(normalizeUserId(userId));
1079
+ const found = session && findTab(session, targetId);
1080
+ if (!found) {
1081
+ return res.status(404).json({ error: 'Tab not found' });
1082
+ }
1083
+
1084
+ const { tabState } = found;
1085
+ tabState.toolCalls++;
1086
+ tabState.refs = await buildRefs(tabState.page);
1087
+
1088
+ const ariaYaml = await getAriaSnapshot(tabState.page);
1089
+
1090
+ // Annotate YAML with ref IDs
1091
+ let annotatedYaml = ariaYaml || '';
1092
+ if (annotatedYaml && tabState.refs.size > 0) {
1093
+ const refsByKey = new Map();
1094
+ for (const [refId, el] of tabState.refs) {
1095
+ const key = `${el.role}:${el.name || ''}`;
1096
+ if (!refsByKey.has(key)) refsByKey.set(key, refId);
1097
+ }
1098
+
1099
+ const lines = annotatedYaml.split('\n');
1100
+ annotatedYaml = lines.map(line => {
1101
+ const match = line.match(/^(\s*)-\s+(\w+)(?:\s+"([^"]*)")?/);
1102
+ if (match) {
1103
+ const [, indent, role, name] = match;
1104
+ const key = `${role}:${name || ''}`;
1105
+ const refId = refsByKey.get(key);
1106
+ if (refId) {
1107
+ return line.replace(/^(\s*-\s+\w+)/, `$1 [${refId}]`);
1108
+ }
1109
+ }
1110
+ return line;
1111
+ }).join('\n');
1112
+ }
1113
+
1114
+ res.json({
1115
+ ok: true,
1116
+ format: 'aria',
1117
+ targetId,
1118
+ url: tabState.page.url(),
1119
+ snapshot: annotatedYaml,
1120
+ refsCount: tabState.refs.size
1121
+ });
1122
+ } catch (err) {
1123
+ console.error('Snapshot error:', err);
1124
+ res.status(500).json({ error: err.message });
1125
+ }
1126
+ });
1127
+
1128
+ // POST /act - Combined action endpoint (OpenClaw format)
1129
+ // Routes to click/type/scroll/press/etc based on 'kind' parameter
1130
+ app.post('/act', async (req, res) => {
1131
+ try {
1132
+ const { kind, targetId, userId = 'openclaw', ...params } = req.body;
1133
+
1134
+ if (!kind) {
1135
+ return res.status(400).json({ error: 'kind is required' });
1136
+ }
1137
+
1138
+ const session = sessions.get(normalizeUserId(userId));
1139
+ const found = session && findTab(session, targetId);
1140
+ if (!found) {
1141
+ return res.status(404).json({ error: 'Tab not found' });
1142
+ }
1143
+
1144
+ const { tabState } = found;
1145
+ tabState.toolCalls++;
1146
+
1147
+ const result = await withTabLock(targetId, async () => {
1148
+ switch (kind) {
1149
+ case 'click': {
1150
+ const { ref, selector, doubleClick } = params;
1151
+ if (!ref && !selector) {
1152
+ throw new Error('ref or selector required');
1153
+ }
1154
+
1155
+ const doClick = async (locatorOrSelector, isLocator) => {
1156
+ const locator = isLocator ? locatorOrSelector : tabState.page.locator(locatorOrSelector);
1157
+ const clickOpts = { timeout: 5000 };
1158
+ if (doubleClick) clickOpts.clickCount = 2;
1159
+
1160
+ try {
1161
+ await locator.click(clickOpts);
1162
+ } catch (err) {
1163
+ if (err.message.includes('intercepts pointer events')) {
1164
+ await locator.click({ ...clickOpts, force: true });
1165
+ } else {
1166
+ throw err;
1167
+ }
1168
+ }
1169
+ };
1170
+
1171
+ if (ref) {
1172
+ const locator = refToLocator(tabState.page, ref, tabState.refs);
1173
+ if (!locator) throw new Error(`Unknown ref: ${ref}`);
1174
+ await doClick(locator, true);
1175
+ } else {
1176
+ await doClick(selector, false);
1177
+ }
1178
+
1179
+ await tabState.page.waitForTimeout(500);
1180
+ tabState.refs = await buildRefs(tabState.page);
1181
+ return { ok: true, targetId, url: tabState.page.url() };
1182
+ }
1183
+
1184
+ case 'type': {
1185
+ const { ref, selector, text, submit } = params;
1186
+ if (!ref && !selector) {
1187
+ throw new Error('ref or selector required');
1188
+ }
1189
+ if (typeof text !== 'string') {
1190
+ throw new Error('text is required');
1191
+ }
1192
+
1193
+ if (ref) {
1194
+ const locator = refToLocator(tabState.page, ref, tabState.refs);
1195
+ if (!locator) throw new Error(`Unknown ref: ${ref}`);
1196
+ await locator.fill(text, { timeout: 10000 });
1197
+ if (submit) await tabState.page.keyboard.press('Enter');
1198
+ } else {
1199
+ await tabState.page.fill(selector, text, { timeout: 10000 });
1200
+ if (submit) await tabState.page.keyboard.press('Enter');
1201
+ }
1202
+ return { ok: true, targetId };
1203
+ }
1204
+
1205
+ case 'press': {
1206
+ const { key } = params;
1207
+ if (!key) throw new Error('key is required');
1208
+ await tabState.page.keyboard.press(key);
1209
+ return { ok: true, targetId };
1210
+ }
1211
+
1212
+ case 'scroll':
1213
+ case 'scrollIntoView': {
1214
+ const { ref, direction = 'down', amount = 500 } = params;
1215
+ if (ref) {
1216
+ const locator = refToLocator(tabState.page, ref, tabState.refs);
1217
+ if (!locator) throw new Error(`Unknown ref: ${ref}`);
1218
+ await locator.scrollIntoViewIfNeeded({ timeout: 5000 });
1219
+ } else {
1220
+ const delta = direction === 'up' ? -amount : amount;
1221
+ await tabState.page.mouse.wheel(0, delta);
1222
+ }
1223
+ await tabState.page.waitForTimeout(300);
1224
+ return { ok: true, targetId };
1225
+ }
1226
+
1227
+ case 'hover': {
1228
+ const { ref, selector } = params;
1229
+ if (!ref && !selector) throw new Error('ref or selector required');
1230
+
1231
+ if (ref) {
1232
+ const locator = refToLocator(tabState.page, ref, tabState.refs);
1233
+ if (!locator) throw new Error(`Unknown ref: ${ref}`);
1234
+ await locator.hover({ timeout: 5000 });
1235
+ } else {
1236
+ await tabState.page.locator(selector).hover({ timeout: 5000 });
1237
+ }
1238
+ return { ok: true, targetId };
1239
+ }
1240
+
1241
+ case 'wait': {
1242
+ const { timeMs, text, loadState } = params;
1243
+ if (timeMs) {
1244
+ await tabState.page.waitForTimeout(timeMs);
1245
+ } else if (text) {
1246
+ await tabState.page.waitForSelector(`text=${text}`, { timeout: 30000 });
1247
+ } else if (loadState) {
1248
+ await tabState.page.waitForLoadState(loadState, { timeout: 30000 });
1249
+ }
1250
+ return { ok: true, targetId, url: tabState.page.url() };
1251
+ }
1252
+
1253
+ case 'close': {
1254
+ await tabState.page.close();
1255
+ found.group.delete(targetId);
1256
+ return { ok: true, targetId };
1257
+ }
1258
+
1259
+ default:
1260
+ throw new Error(`Unsupported action kind: ${kind}`);
1261
+ }
1262
+ });
1263
+
1264
+ res.json(result);
1265
+ } catch (err) {
1266
+ console.error('Act error:', err);
1267
+ res.status(500).json({ error: err.message });
1268
+ }
1269
+ });
1270
+
1271
+ // Graceful shutdown
1272
+ process.on('SIGTERM', async () => {
1273
+ console.log('Shutting down...');
1274
+ for (const [userId, session] of sessions) {
1275
+ await session.context.close().catch(() => {});
1276
+ }
1277
+ if (browser) await browser.close().catch(() => {});
1278
+ process.exit(0);
1279
+ });
1280
+
1281
+ const PORT = process.env.PORT || 9377;
1282
+ app.listen(PORT, async () => {
1283
+ console.log(`🦊 camoufox-browser listening on port ${PORT}`);
1284
+ // Pre-launch browser so it's ready for first request
1285
+ await ensureBrowser().catch(err => {
1286
+ console.error('Failed to pre-launch browser:', err.message);
1287
+ });
1288
+ });