@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.
- package/.env.bak +4 -0
- package/.github/workflows/deploy.yml +21 -0
- package/AGENTS.md +153 -0
- package/Dockerfile.camoufox +59 -0
- package/LICENSE +21 -0
- package/README.md +234 -0
- package/SKILL.md +165 -0
- package/experimental/chromium/Dockerfile +35 -0
- package/experimental/chromium/README.md +47 -0
- package/experimental/chromium/run.sh +24 -0
- package/experimental/chromium/server.js +812 -0
- package/fly.toml +29 -0
- package/jest.config.js +41 -0
- package/lib/macros.js +30 -0
- package/openclaw.plugin.json +31 -0
- package/package.json +30 -0
- package/plugin.ts +312 -0
- package/run-camoufox.sh +37 -0
- package/server-camoufox.js +946 -0
- package/tests/e2e/concurrency.test.js +103 -0
- package/tests/e2e/formSubmission.test.js +129 -0
- package/tests/e2e/macroNavigation.test.js +92 -0
- package/tests/e2e/navigation.test.js +128 -0
- package/tests/e2e/scroll.test.js +81 -0
- package/tests/e2e/snapshotLinks.test.js +141 -0
- package/tests/e2e/tabLifecycle.test.js +149 -0
- package/tests/e2e/typingEnter.test.js +147 -0
- package/tests/helpers/client.js +222 -0
- package/tests/helpers/startJoBrowser.js +95 -0
- package/tests/helpers/testSite.js +238 -0
- package/tests/live/googleSearch.test.js +93 -0
- package/tests/live/macroExpansion.test.js +132 -0
- package/tests/unit/macros.test.js +123 -0
|
@@ -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
|
+
});
|