@askjo/camoufox-browser 1.0.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,946 @@
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<listItemId, Map<tabId, TabState>>, lastAccess }
14
+ // TabState = { page, refs: Map<refId, {role, name, nth}>, visitedUrls: Set, toolCalls: number }
15
+ const sessions = new Map();
16
+
17
+ const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 min
18
+ const MAX_SNAPSHOT_NODES = 500;
19
+ const DEBUG_RESPONSES = true; // Log response payloads
20
+
21
+ function logResponse(endpoint, data) {
22
+ if (!DEBUG_RESPONSES) return;
23
+ let logData = data;
24
+ // Truncate snapshot for readability
25
+ if (data && data.snapshot) {
26
+ const snap = data.snapshot;
27
+ logData = { ...data, snapshot: `[${snap.length} chars] ${snap.slice(0, 300)}...` };
28
+ }
29
+ console.log(`📤 ${endpoint} ->`, JSON.stringify(logData, null, 2));
30
+ }
31
+
32
+ // Per-tab locks to serialize operations on the same tab
33
+ // tabId -> Promise (the currently executing operation)
34
+ const tabLocks = new Map();
35
+
36
+ async function withTabLock(tabId, operation) {
37
+ // Wait for any pending operation on this tab to complete
38
+ const pending = tabLocks.get(tabId);
39
+ if (pending) {
40
+ try {
41
+ await pending;
42
+ } catch (e) {
43
+ // Previous operation failed, continue anyway
44
+ }
45
+ }
46
+
47
+ // Execute this operation and store the promise
48
+ const promise = operation();
49
+ tabLocks.set(tabId, promise);
50
+
51
+ try {
52
+ return await promise;
53
+ } finally {
54
+ // Clean up if this is still the active lock
55
+ if (tabLocks.get(tabId) === promise) {
56
+ tabLocks.delete(tabId);
57
+ }
58
+ }
59
+ }
60
+
61
+ // Detect host OS for fingerprint generation
62
+ function getHostOS() {
63
+ const platform = os.platform();
64
+ if (platform === 'darwin') return 'macos';
65
+ if (platform === 'win32') return 'windows';
66
+ return 'linux';
67
+ }
68
+
69
+ async function ensureBrowser() {
70
+ if (!browser) {
71
+ const hostOS = getHostOS();
72
+ console.log(`Launching Camoufox browser (host OS: ${hostOS})...`);
73
+
74
+ const options = await launchOptions({
75
+ headless: true,
76
+ os: hostOS,
77
+ humanize: true,
78
+ enable_cache: true,
79
+ });
80
+
81
+ browser = await firefox.launch(options);
82
+ console.log('Camoufox browser launched');
83
+ }
84
+ return browser;
85
+ }
86
+
87
+ // Helper to normalize userId to string (JSON body may parse as number)
88
+ function normalizeUserId(userId) {
89
+ return String(userId);
90
+ }
91
+
92
+ async function getSession(userId) {
93
+ const key = normalizeUserId(userId);
94
+ let session = sessions.get(key);
95
+ if (!session) {
96
+ const b = await ensureBrowser();
97
+ const context = await b.newContext({
98
+ viewport: { width: 1280, height: 720 },
99
+ locale: 'en-US',
100
+ timezoneId: 'America/Los_Angeles',
101
+ geolocation: { latitude: 37.7749, longitude: -122.4194 },
102
+ permissions: ['geolocation'],
103
+ });
104
+
105
+ session = { context, tabGroups: new Map(), lastAccess: Date.now() };
106
+ sessions.set(key, session);
107
+ console.log(`Session created for user ${key}`);
108
+ }
109
+ session.lastAccess = Date.now();
110
+ return session;
111
+ }
112
+
113
+ function getTabGroup(session, listItemId) {
114
+ let group = session.tabGroups.get(listItemId);
115
+ if (!group) {
116
+ group = new Map();
117
+ session.tabGroups.set(listItemId, group);
118
+ }
119
+ return group;
120
+ }
121
+
122
+ function findTab(session, tabId) {
123
+ for (const [listItemId, group] of session.tabGroups) {
124
+ if (group.has(tabId)) {
125
+ const tabState = group.get(tabId);
126
+ return { tabState, listItemId, group };
127
+ }
128
+ }
129
+ return null;
130
+ }
131
+
132
+ function createTabState(page) {
133
+ return {
134
+ page,
135
+ refs: new Map(),
136
+ visitedUrls: new Set(),
137
+ toolCalls: 0
138
+ };
139
+ }
140
+
141
+ async function waitForPageReady(page, options = {}) {
142
+ const { timeout = 10000, waitForNetwork = true } = options;
143
+
144
+ try {
145
+ await page.waitForLoadState('domcontentloaded', { timeout });
146
+
147
+ if (waitForNetwork) {
148
+ await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {
149
+ console.log('waitForPageReady: networkidle timeout (continuing anyway)');
150
+ });
151
+ }
152
+
153
+ // Framework hydration wait (React/Next.js/Vue) - mirrors Swift WebView.swift logic
154
+ // Wait for readyState === 'complete' + network quiet (40 iterations × 250ms max)
155
+ await page.evaluate(async () => {
156
+ for (let i = 0; i < 40; i++) {
157
+ // Check if network is quiet (no recent resource loads)
158
+ const entries = performance.getEntriesByType('resource');
159
+ const recentEntries = entries.slice(-5);
160
+ const netQuiet = recentEntries.every(e => (performance.now() - e.responseEnd) > 400);
161
+
162
+ if (document.readyState === 'complete' && netQuiet) {
163
+ // Double RAF to ensure paint is complete
164
+ await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
165
+ break;
166
+ }
167
+ await new Promise(r => setTimeout(r, 250));
168
+ }
169
+ }).catch(() => {
170
+ console.log('waitForPageReady: framework hydration wait failed (continuing anyway)');
171
+ });
172
+
173
+ await page.waitForTimeout(200);
174
+
175
+ // Auto-dismiss common consent/privacy dialogs
176
+ await dismissConsentDialogs(page);
177
+
178
+ return true;
179
+ } catch (err) {
180
+ console.log(`waitForPageReady: ${err.message}`);
181
+ return false;
182
+ }
183
+ }
184
+
185
+ async function dismissConsentDialogs(page) {
186
+ // Common consent/privacy dialog selectors (matches Swift WebView.swift patterns)
187
+ const dismissSelectors = [
188
+ // OneTrust (very common)
189
+ '#onetrust-banner-sdk button#onetrust-accept-btn-handler',
190
+ '#onetrust-banner-sdk button#onetrust-reject-all-handler',
191
+ '#onetrust-close-btn-container button',
192
+ // Generic patterns
193
+ 'button[data-test="cookie-accept-all"]',
194
+ 'button[aria-label="Accept all"]',
195
+ 'button[aria-label="Accept All"]',
196
+ 'button[aria-label="Close"]',
197
+ 'button[aria-label="Dismiss"]',
198
+ // Dialog close buttons
199
+ 'dialog button:has-text("Close")',
200
+ 'dialog button:has-text("Accept")',
201
+ 'dialog button:has-text("I Accept")',
202
+ 'dialog button:has-text("Got it")',
203
+ 'dialog button:has-text("OK")',
204
+ // GDPR/CCPA specific
205
+ '[class*="consent"] button[class*="accept"]',
206
+ '[class*="consent"] button[class*="close"]',
207
+ '[class*="privacy"] button[class*="close"]',
208
+ '[class*="cookie"] button[class*="accept"]',
209
+ '[class*="cookie"] button[class*="close"]',
210
+ // Overlay close buttons
211
+ '[class*="modal"] button[class*="close"]',
212
+ '[class*="overlay"] button[class*="close"]',
213
+ ];
214
+
215
+ for (const selector of dismissSelectors) {
216
+ try {
217
+ const button = page.locator(selector).first();
218
+ if (await button.isVisible({ timeout: 100 })) {
219
+ await button.click({ timeout: 1000 }).catch(() => {});
220
+ console.log(`🍪 Auto-dismissed consent dialog via: ${selector}`);
221
+ await page.waitForTimeout(300); // Brief pause after dismiss
222
+ break; // Only dismiss one dialog per page load
223
+ }
224
+ } catch (e) {
225
+ // Selector not found or not clickable, continue
226
+ }
227
+ }
228
+ }
229
+
230
+ async function buildRefs(page) {
231
+ const refs = new Map();
232
+
233
+ if (!page || page.isClosed()) {
234
+ console.log('buildRefs: Page is closed or invalid');
235
+ return refs;
236
+ }
237
+
238
+ await waitForPageReady(page, { waitForNetwork: false });
239
+
240
+ // Get ARIA snapshot including shadow DOM content
241
+ // Playwright's ariaSnapshot already traverses shadow roots, but we also
242
+ // inject a script to collect shadow DOM elements for additional coverage
243
+ let ariaYaml;
244
+ try {
245
+ ariaYaml = await page.locator('body').ariaSnapshot({ timeout: 10000 });
246
+ } catch (err) {
247
+ console.log('buildRefs: ariaSnapshot failed, retrying after navigation settles');
248
+ await page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
249
+ ariaYaml = await page.locator('body').ariaSnapshot({ timeout: 10000 });
250
+ }
251
+
252
+ // Collect additional interactive elements from shadow DOM
253
+ const shadowElements = await page.evaluate(() => {
254
+ const elements = [];
255
+ const collectFromShadow = (root, depth = 0) => {
256
+ if (depth > 5) return; // Limit recursion
257
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
258
+ while (walker.nextNode()) {
259
+ const el = walker.currentNode;
260
+ if (el.shadowRoot) {
261
+ collectFromShadow(el.shadowRoot, depth + 1);
262
+ }
263
+ }
264
+ };
265
+ // Start collection from all shadow roots
266
+ document.querySelectorAll('*').forEach(el => {
267
+ if (el.shadowRoot) collectFromShadow(el.shadowRoot);
268
+ });
269
+ return elements;
270
+ }).catch(() => []);
271
+
272
+ if (!ariaYaml) {
273
+ console.log('buildRefs: No aria snapshot available');
274
+ return refs;
275
+ }
276
+
277
+ const lines = ariaYaml.split('\n');
278
+ let refCounter = 1;
279
+
280
+ // Interactive roles to include - exclude combobox to avoid opening complex widgets
281
+ // (date pickers, dropdowns) that can interfere with navigation
282
+ const interactiveRoles = [
283
+ 'button', 'link', 'textbox', 'checkbox', 'radio',
284
+ 'menuitem', 'tab', 'searchbox', 'slider', 'spinbutton', 'switch'
285
+ // 'combobox' excluded - can trigger date pickers and complex dropdowns
286
+ ];
287
+
288
+ // Patterns to skip (date pickers, calendar widgets)
289
+ const skipPatterns = [
290
+ /date/i, /calendar/i, /picker/i, /datepicker/i
291
+ ];
292
+
293
+ // Track occurrences of each role+name combo for nth disambiguation
294
+ const seenCounts = new Map(); // "role:name" -> count
295
+
296
+ for (const line of lines) {
297
+ if (refCounter > MAX_SNAPSHOT_NODES) break;
298
+
299
+ const match = line.match(/^\s*-\s+(\w+)(?:\s+"([^"]*)")?/);
300
+ if (match) {
301
+ const [, role, name] = match;
302
+ const normalizedRole = role.toLowerCase();
303
+
304
+ // Skip combobox role entirely (date pickers, complex dropdowns)
305
+ if (normalizedRole === 'combobox') continue;
306
+
307
+ // Skip elements with date/calendar-related names
308
+ if (name && skipPatterns.some(p => p.test(name))) continue;
309
+
310
+ if (interactiveRoles.includes(normalizedRole)) {
311
+ const normalizedName = name || '';
312
+ const key = `${normalizedRole}:${normalizedName}`;
313
+
314
+ // Get current count and increment
315
+ const nth = seenCounts.get(key) || 0;
316
+ seenCounts.set(key, nth + 1);
317
+
318
+ const refId = `e${refCounter++}`;
319
+ refs.set(refId, { role: normalizedRole, name: normalizedName, nth });
320
+ }
321
+ }
322
+ }
323
+
324
+ return refs;
325
+ }
326
+
327
+ async function getAriaSnapshot(page) {
328
+ if (!page || page.isClosed()) {
329
+ return null;
330
+ }
331
+ await waitForPageReady(page, { waitForNetwork: false });
332
+ return await page.locator('body').ariaSnapshot({ timeout: 10000 });
333
+ }
334
+
335
+ function refToLocator(page, ref, refs) {
336
+ const info = refs.get(ref);
337
+ if (!info) return null;
338
+
339
+ const { role, name, nth } = info;
340
+ let locator = page.getByRole(role, name ? { name } : undefined);
341
+
342
+ // Always use .nth() to disambiguate duplicate role+name combinations
343
+ // This avoids "strict mode violation" when multiple elements match
344
+ locator = locator.nth(nth);
345
+
346
+ return locator;
347
+ }
348
+
349
+ // Health check
350
+ app.get('/health', async (req, res) => {
351
+ try {
352
+ const b = await ensureBrowser();
353
+ res.json({
354
+ ok: true,
355
+ engine: 'camoufox',
356
+ sessions: sessions.size,
357
+ browserConnected: b.isConnected()
358
+ });
359
+ } catch (err) {
360
+ res.status(500).json({ ok: false, error: err.message });
361
+ }
362
+ });
363
+
364
+ // Create new tab
365
+ app.post('/tabs', async (req, res) => {
366
+ try {
367
+ const { userId, listItemId, url } = req.body;
368
+ if (!userId || !listItemId) {
369
+ return res.status(400).json({ error: 'userId and listItemId required' });
370
+ }
371
+
372
+ const session = await getSession(userId);
373
+ const group = getTabGroup(session, listItemId);
374
+
375
+ const page = await session.context.newPage();
376
+ const tabId = crypto.randomUUID();
377
+ const tabState = createTabState(page);
378
+ group.set(tabId, tabState);
379
+
380
+ if (url) {
381
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
382
+ tabState.visitedUrls.add(url);
383
+ }
384
+
385
+ console.log(`Tab ${tabId} created for user ${userId}, listItem ${listItemId}`);
386
+ res.json({ tabId, url: page.url() });
387
+ } catch (err) {
388
+ console.error('Create tab error:', err);
389
+ res.status(500).json({ error: err.message });
390
+ }
391
+ });
392
+
393
+ // Navigate
394
+ app.post('/tabs/:tabId/navigate', async (req, res) => {
395
+ const tabId = req.params.tabId;
396
+
397
+ try {
398
+ const { userId, url, macro, query } = req.body;
399
+ const session = sessions.get(normalizeUserId(userId));
400
+ const found = session && findTab(session, tabId);
401
+ if (!found) return res.status(404).json({ error: 'Tab not found' });
402
+
403
+ const { tabState } = found;
404
+ tabState.toolCalls++;
405
+
406
+ let targetUrl = url;
407
+ if (macro) {
408
+ targetUrl = expandMacro(macro, query) || url;
409
+ }
410
+
411
+ if (!targetUrl) {
412
+ return res.status(400).json({ error: 'url or macro required' });
413
+ }
414
+
415
+ // Serialize navigation operations on the same tab
416
+ const result = await withTabLock(tabId, async () => {
417
+ await tabState.page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
418
+ tabState.visitedUrls.add(targetUrl);
419
+ tabState.refs = await buildRefs(tabState.page);
420
+ return { ok: true, url: tabState.page.url() };
421
+ });
422
+
423
+ logResponse(`POST /tabs/${tabId}/navigate`, result);
424
+ res.json(result);
425
+ } catch (err) {
426
+ console.error('Navigate error:', err);
427
+ res.status(500).json({ error: err.message });
428
+ }
429
+ });
430
+
431
+ // Snapshot
432
+ app.get('/tabs/:tabId/snapshot', async (req, res) => {
433
+ try {
434
+ const userId = req.query.userId;
435
+ const format = req.query.format || 'text';
436
+ const session = sessions.get(normalizeUserId(userId));
437
+ const found = session && findTab(session, req.params.tabId);
438
+ if (!found) return res.status(404).json({ error: 'Tab not found' });
439
+
440
+ const { tabState } = found;
441
+ tabState.toolCalls++;
442
+ tabState.refs = await buildRefs(tabState.page);
443
+
444
+ const ariaYaml = await getAriaSnapshot(tabState.page);
445
+
446
+ // Annotate YAML with ref IDs for interactive elements
447
+ let annotatedYaml = ariaYaml || '';
448
+ if (annotatedYaml && tabState.refs.size > 0) {
449
+ // Build a map of role+name -> refId for annotation
450
+ const refsByKey = new Map();
451
+ const seenCounts = new Map();
452
+ for (const [refId, info] of tabState.refs) {
453
+ const key = `${info.role}:${info.name}:${info.nth}`;
454
+ refsByKey.set(key, refId);
455
+ }
456
+
457
+ // Track occurrences while annotating
458
+ const annotationCounts = new Map();
459
+ const lines = annotatedYaml.split('\n');
460
+ // Must match buildRefs - excludes combobox to avoid date pickers/complex dropdowns
461
+ const interactiveRoles = [
462
+ 'button', 'link', 'textbox', 'checkbox', 'radio',
463
+ 'menuitem', 'tab', 'searchbox', 'slider', 'spinbutton', 'switch'
464
+ ];
465
+ const skipPatterns = [/date/i, /calendar/i, /picker/i, /datepicker/i];
466
+
467
+ annotatedYaml = lines.map(line => {
468
+ const match = line.match(/^(\s*-\s+)(\w+)(\s+"([^"]*)")?(.*)$/);
469
+ if (match) {
470
+ const [, prefix, role, nameMatch, name, suffix] = match;
471
+ const normalizedRole = role.toLowerCase();
472
+
473
+ // Skip combobox and date-related elements (same as buildRefs)
474
+ if (normalizedRole === 'combobox') return line;
475
+ if (name && skipPatterns.some(p => p.test(name))) return line;
476
+
477
+ if (interactiveRoles.includes(normalizedRole)) {
478
+ const normalizedName = name || '';
479
+ const countKey = `${normalizedRole}:${normalizedName}`;
480
+ const nth = annotationCounts.get(countKey) || 0;
481
+ annotationCounts.set(countKey, nth + 1);
482
+
483
+ const key = `${normalizedRole}:${normalizedName}:${nth}`;
484
+ const refId = refsByKey.get(key);
485
+ if (refId) {
486
+ return `${prefix}${role}${nameMatch || ''} [${refId}]${suffix}`;
487
+ }
488
+ }
489
+ }
490
+ return line;
491
+ }).join('\n');
492
+ }
493
+
494
+ const result = {
495
+ url: tabState.page.url(),
496
+ snapshot: annotatedYaml,
497
+ refsCount: tabState.refs.size
498
+ };
499
+ logResponse(`GET /tabs/${req.params.tabId}/snapshot`, result);
500
+ res.json(result);
501
+ } catch (err) {
502
+ console.error('Snapshot error:', err);
503
+ res.status(500).json({ error: err.message });
504
+ }
505
+ });
506
+
507
+ // Wait for page ready
508
+ app.post('/tabs/:tabId/wait', async (req, res) => {
509
+ try {
510
+ const { userId, timeout = 10000, waitForNetwork = true } = req.body;
511
+ const session = sessions.get(normalizeUserId(userId));
512
+ const found = session && findTab(session, req.params.tabId);
513
+ if (!found) return res.status(404).json({ error: 'Tab not found' });
514
+
515
+ const { tabState } = found;
516
+ const ready = await waitForPageReady(tabState.page, { timeout, waitForNetwork });
517
+
518
+ res.json({ ok: true, ready });
519
+ } catch (err) {
520
+ console.error('Wait error:', err);
521
+ res.status(500).json({ error: err.message });
522
+ }
523
+ });
524
+
525
+ // Click
526
+ app.post('/tabs/:tabId/click', async (req, res) => {
527
+ const tabId = req.params.tabId;
528
+
529
+ try {
530
+ const { userId, ref, selector } = req.body;
531
+ const session = sessions.get(normalizeUserId(userId));
532
+ const found = session && findTab(session, tabId);
533
+ if (!found) return res.status(404).json({ error: 'Tab not found' });
534
+
535
+ const { tabState } = found;
536
+ tabState.toolCalls++;
537
+
538
+ if (!ref && !selector) {
539
+ return res.status(400).json({ error: 'ref or selector required' });
540
+ }
541
+
542
+ const result = await withTabLock(tabId, async () => {
543
+ // Full mouse event sequence for stubborn JS click handlers (mirrors Swift WebView.swift)
544
+ // Dispatches: mouseover → mouseenter → mousedown → mouseup → click
545
+ const dispatchMouseSequence = async (locator) => {
546
+ const box = await locator.boundingBox();
547
+ if (!box) throw new Error('Element not visible (no bounding box)');
548
+
549
+ const x = box.x + box.width / 2;
550
+ const y = box.y + box.height / 2;
551
+
552
+ // Move mouse to element (triggers mouseover/mouseenter)
553
+ await tabState.page.mouse.move(x, y);
554
+ await tabState.page.waitForTimeout(50);
555
+
556
+ // Full click sequence
557
+ await tabState.page.mouse.down();
558
+ await tabState.page.waitForTimeout(50);
559
+ await tabState.page.mouse.up();
560
+
561
+ console.log(`🖱️ Dispatched full mouse sequence at (${x.toFixed(0)}, ${y.toFixed(0)})`);
562
+ };
563
+
564
+ const doClick = async (locatorOrSelector, isLocator) => {
565
+ const locator = isLocator ? locatorOrSelector : tabState.page.locator(locatorOrSelector);
566
+
567
+ try {
568
+ // First try normal click (respects visibility, enabled, not-obscured)
569
+ await locator.click({ timeout: 5000 });
570
+ } catch (err) {
571
+ // Fallback 1: If intercepted by overlay, retry with force
572
+ if (err.message.includes('intercepts pointer events')) {
573
+ console.log('Click intercepted, retrying with force:true');
574
+ try {
575
+ await locator.click({ timeout: 5000, force: true });
576
+ } catch (forceErr) {
577
+ // Fallback 2: Full mouse event sequence for stubborn JS handlers
578
+ console.log('Force click failed, trying full mouse sequence');
579
+ await dispatchMouseSequence(locator);
580
+ }
581
+ } else if (err.message.includes('not visible') || err.message.includes('timeout')) {
582
+ // Fallback 2: Element not responding to click, try mouse sequence
583
+ console.log('Click timeout/not visible, trying full mouse sequence');
584
+ await dispatchMouseSequence(locator);
585
+ } else {
586
+ throw err;
587
+ }
588
+ }
589
+ };
590
+
591
+ if (ref) {
592
+ const locator = refToLocator(tabState.page, ref, tabState.refs);
593
+ if (!locator) {
594
+ const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none';
595
+ throw new Error(`Unknown ref: ${ref} (valid refs: e1-${maxRef}, ${tabState.refs.size} total). Refs reset after navigation - call snapshot first.`);
596
+ }
597
+ await doClick(locator, true);
598
+ } else {
599
+ await doClick(selector, false);
600
+ }
601
+
602
+ await tabState.page.waitForTimeout(500);
603
+ tabState.refs = await buildRefs(tabState.page);
604
+
605
+ const newUrl = tabState.page.url();
606
+ tabState.visitedUrls.add(newUrl);
607
+ return { ok: true, url: newUrl };
608
+ });
609
+
610
+ logResponse(`POST /tabs/${tabId}/click`, result);
611
+ res.json(result);
612
+ } catch (err) {
613
+ console.error('Click error:', err);
614
+ res.status(500).json({ error: err.message });
615
+ }
616
+ });
617
+
618
+ // Type
619
+ app.post('/tabs/:tabId/type', async (req, res) => {
620
+ const tabId = req.params.tabId;
621
+
622
+ try {
623
+ const { userId, ref, selector, text } = req.body;
624
+ const session = sessions.get(normalizeUserId(userId));
625
+ const found = session && findTab(session, tabId);
626
+ if (!found) return res.status(404).json({ error: 'Tab not found' });
627
+
628
+ const { tabState } = found;
629
+ tabState.toolCalls++;
630
+
631
+ if (!ref && !selector) {
632
+ return res.status(400).json({ error: 'ref or selector required' });
633
+ }
634
+
635
+ await withTabLock(tabId, async () => {
636
+ if (ref) {
637
+ const locator = refToLocator(tabState.page, ref, tabState.refs);
638
+ if (!locator) throw new Error(`Unknown ref: ${ref}`);
639
+ await locator.fill(text, { timeout: 10000 });
640
+ } else {
641
+ await tabState.page.fill(selector, text, { timeout: 10000 });
642
+ }
643
+ });
644
+
645
+ res.json({ ok: true });
646
+ } catch (err) {
647
+ console.error('Type error:', err);
648
+ res.status(500).json({ error: err.message });
649
+ }
650
+ });
651
+
652
+ // Press key
653
+ app.post('/tabs/:tabId/press', async (req, res) => {
654
+ const tabId = req.params.tabId;
655
+
656
+ try {
657
+ const { userId, key } = req.body;
658
+ const session = sessions.get(normalizeUserId(userId));
659
+ const found = session && findTab(session, tabId);
660
+ if (!found) return res.status(404).json({ error: 'Tab not found' });
661
+
662
+ const { tabState } = found;
663
+ tabState.toolCalls++;
664
+
665
+ await withTabLock(tabId, async () => {
666
+ await tabState.page.keyboard.press(key);
667
+ });
668
+
669
+ res.json({ ok: true });
670
+ } catch (err) {
671
+ console.error('Press error:', err);
672
+ res.status(500).json({ error: err.message });
673
+ }
674
+ });
675
+
676
+ // Scroll
677
+ app.post('/tabs/:tabId/scroll', async (req, res) => {
678
+ try {
679
+ const { userId, direction = 'down', amount = 500 } = req.body;
680
+ const session = sessions.get(normalizeUserId(userId));
681
+ const found = session && findTab(session, req.params.tabId);
682
+ if (!found) return res.status(404).json({ error: 'Tab not found' });
683
+
684
+ const { tabState } = found;
685
+ tabState.toolCalls++;
686
+
687
+ const delta = direction === 'up' ? -amount : amount;
688
+ await tabState.page.mouse.wheel(0, delta);
689
+ await tabState.page.waitForTimeout(300);
690
+
691
+ res.json({ ok: true });
692
+ } catch (err) {
693
+ console.error('Scroll error:', err);
694
+ res.status(500).json({ error: err.message });
695
+ }
696
+ });
697
+
698
+ // Back
699
+ app.post('/tabs/:tabId/back', async (req, res) => {
700
+ const tabId = req.params.tabId;
701
+
702
+ try {
703
+ const { userId } = req.body;
704
+ const session = sessions.get(normalizeUserId(userId));
705
+ const found = session && findTab(session, tabId);
706
+ if (!found) return res.status(404).json({ error: 'Tab not found' });
707
+
708
+ const { tabState } = found;
709
+ tabState.toolCalls++;
710
+
711
+ const result = await withTabLock(tabId, async () => {
712
+ await tabState.page.goBack({ timeout: 10000 });
713
+ tabState.refs = await buildRefs(tabState.page);
714
+ return { ok: true, url: tabState.page.url() };
715
+ });
716
+
717
+ res.json(result);
718
+ } catch (err) {
719
+ console.error('Back error:', err);
720
+ res.status(500).json({ error: err.message });
721
+ }
722
+ });
723
+
724
+ // Forward
725
+ app.post('/tabs/:tabId/forward', async (req, res) => {
726
+ const tabId = req.params.tabId;
727
+
728
+ try {
729
+ const { userId } = req.body;
730
+ const session = sessions.get(normalizeUserId(userId));
731
+ const found = session && findTab(session, tabId);
732
+ if (!found) return res.status(404).json({ error: 'Tab not found' });
733
+
734
+ const { tabState } = found;
735
+ tabState.toolCalls++;
736
+
737
+ const result = await withTabLock(tabId, async () => {
738
+ await tabState.page.goForward({ timeout: 10000 });
739
+ tabState.refs = await buildRefs(tabState.page);
740
+ return { ok: true, url: tabState.page.url() };
741
+ });
742
+
743
+ res.json(result);
744
+ } catch (err) {
745
+ console.error('Forward error:', err);
746
+ res.status(500).json({ error: err.message });
747
+ }
748
+ });
749
+
750
+ // Refresh
751
+ app.post('/tabs/:tabId/refresh', async (req, res) => {
752
+ const tabId = req.params.tabId;
753
+
754
+ try {
755
+ const { userId } = req.body;
756
+ const session = sessions.get(normalizeUserId(userId));
757
+ const found = session && findTab(session, tabId);
758
+ if (!found) return res.status(404).json({ error: 'Tab not found' });
759
+
760
+ const { tabState } = found;
761
+ tabState.toolCalls++;
762
+
763
+ const result = await withTabLock(tabId, async () => {
764
+ await tabState.page.reload({ timeout: 30000 });
765
+ tabState.refs = await buildRefs(tabState.page);
766
+ return { ok: true, url: tabState.page.url() };
767
+ });
768
+
769
+ res.json(result);
770
+ } catch (err) {
771
+ console.error('Refresh error:', err);
772
+ res.status(500).json({ error: err.message });
773
+ }
774
+ });
775
+
776
+ // Get links
777
+ app.get('/tabs/:tabId/links', async (req, res) => {
778
+ try {
779
+ const userId = req.query.userId;
780
+ const limit = parseInt(req.query.limit) || 50;
781
+ const offset = parseInt(req.query.offset) || 0;
782
+ const session = sessions.get(normalizeUserId(userId));
783
+ const found = session && findTab(session, req.params.tabId);
784
+ if (!found) {
785
+ console.log(`GET /tabs/${req.params.tabId}/links -> 404 (userId=${userId}, hasSession=${!!session}, sessionUsers=${[...sessions.keys()].join(',')})`);
786
+ return res.status(404).json({ error: 'Tab not found' });
787
+ }
788
+
789
+ const { tabState } = found;
790
+ tabState.toolCalls++;
791
+
792
+ const allLinks = await tabState.page.evaluate(() => {
793
+ const links = [];
794
+ document.querySelectorAll('a[href]').forEach(a => {
795
+ const href = a.href;
796
+ const text = a.textContent?.trim().slice(0, 100) || '';
797
+ if (href && href.startsWith('http')) {
798
+ links.push({ url: href, text });
799
+ }
800
+ });
801
+ return links;
802
+ });
803
+
804
+ const total = allLinks.length;
805
+ const paginated = allLinks.slice(offset, offset + limit);
806
+
807
+ res.json({
808
+ links: paginated,
809
+ pagination: { total, offset, limit, hasMore: offset + limit < total }
810
+ });
811
+ } catch (err) {
812
+ console.error('Links error:', err);
813
+ res.status(500).json({ error: err.message });
814
+ }
815
+ });
816
+
817
+ // Screenshot
818
+ app.get('/tabs/:tabId/screenshot', async (req, res) => {
819
+ try {
820
+ const userId = req.query.userId;
821
+ const fullPage = req.query.fullPage === 'true';
822
+ const session = sessions.get(normalizeUserId(userId));
823
+ const found = session && findTab(session, req.params.tabId);
824
+ if (!found) return res.status(404).json({ error: 'Tab not found' });
825
+
826
+ const { tabState } = found;
827
+ const buffer = await tabState.page.screenshot({ type: 'png', fullPage });
828
+ res.set('Content-Type', 'image/png');
829
+ res.send(buffer);
830
+ } catch (err) {
831
+ console.error('Screenshot error:', err);
832
+ res.status(500).json({ error: err.message });
833
+ }
834
+ });
835
+
836
+ // Stats
837
+ app.get('/tabs/:tabId/stats', async (req, res) => {
838
+ try {
839
+ const userId = req.query.userId;
840
+ const session = sessions.get(normalizeUserId(userId));
841
+ const found = session && findTab(session, req.params.tabId);
842
+ if (!found) return res.status(404).json({ error: 'Tab not found' });
843
+
844
+ const { tabState, listItemId } = found;
845
+ res.json({
846
+ tabId: req.params.tabId,
847
+ listItemId,
848
+ url: tabState.page.url(),
849
+ visitedUrls: Array.from(tabState.visitedUrls),
850
+ toolCalls: tabState.toolCalls,
851
+ refsCount: tabState.refs.size
852
+ });
853
+ } catch (err) {
854
+ console.error('Stats error:', err);
855
+ res.status(500).json({ error: err.message });
856
+ }
857
+ });
858
+
859
+ // Close tab
860
+ app.delete('/tabs/:tabId', async (req, res) => {
861
+ try {
862
+ const { userId } = req.body;
863
+ const session = sessions.get(normalizeUserId(userId));
864
+ const found = session && findTab(session, req.params.tabId);
865
+ if (found) {
866
+ await found.tabState.page.close();
867
+ found.group.delete(req.params.tabId);
868
+ if (found.group.size === 0) {
869
+ session.tabGroups.delete(found.listItemId);
870
+ }
871
+ console.log(`Tab ${req.params.tabId} closed for user ${userId}`);
872
+ }
873
+ res.json({ ok: true });
874
+ } catch (err) {
875
+ console.error('Close tab error:', err);
876
+ res.status(500).json({ error: err.message });
877
+ }
878
+ });
879
+
880
+ // Close tab group
881
+ app.delete('/tabs/group/:listItemId', async (req, res) => {
882
+ try {
883
+ const { userId } = req.body;
884
+ const session = sessions.get(normalizeUserId(userId));
885
+ const group = session?.tabGroups.get(req.params.listItemId);
886
+ if (group) {
887
+ for (const [tabId, tabState] of group) {
888
+ await tabState.page.close().catch(() => {});
889
+ }
890
+ session.tabGroups.delete(req.params.listItemId);
891
+ console.log(`Tab group ${req.params.listItemId} closed for user ${userId}`);
892
+ }
893
+ res.json({ ok: true });
894
+ } catch (err) {
895
+ console.error('Close tab group error:', err);
896
+ res.status(500).json({ error: err.message });
897
+ }
898
+ });
899
+
900
+ // Close session
901
+ app.delete('/sessions/:userId', async (req, res) => {
902
+ try {
903
+ const userId = req.params.userId;
904
+ const session = sessions.get(normalizeUserId(userId));
905
+ if (session) {
906
+ await session.context.close();
907
+ sessions.delete(userId);
908
+ console.log(`Session closed for user ${userId}`);
909
+ }
910
+ res.json({ ok: true });
911
+ } catch (err) {
912
+ console.error('Close session error:', err);
913
+ res.status(500).json({ error: err.message });
914
+ }
915
+ });
916
+
917
+ // Cleanup stale sessions
918
+ setInterval(() => {
919
+ const now = Date.now();
920
+ for (const [userId, session] of sessions) {
921
+ if (now - session.lastAccess > SESSION_TIMEOUT_MS) {
922
+ session.context.close().catch(() => {});
923
+ sessions.delete(userId);
924
+ console.log(`Session expired for user ${userId}`);
925
+ }
926
+ }
927
+ }, 60_000);
928
+
929
+ // Graceful shutdown
930
+ process.on('SIGTERM', async () => {
931
+ console.log('Shutting down...');
932
+ for (const [userId, session] of sessions) {
933
+ await session.context.close().catch(() => {});
934
+ }
935
+ if (browser) await browser.close().catch(() => {});
936
+ process.exit(0);
937
+ });
938
+
939
+ const PORT = process.env.PORT || 3000;
940
+ app.listen(PORT, async () => {
941
+ console.log(`🦊 camoufox-browser listening on port ${PORT}`);
942
+ // Pre-launch browser so it's ready for first request
943
+ await ensureBrowser().catch(err => {
944
+ console.error('Failed to pre-launch browser:', err.message);
945
+ });
946
+ });