6129 lines
211 KiB
JavaScript
6129 lines
211 KiB
JavaScript
import { Camoufox, launchOptions } from 'camoufox-js';
|
|
import { VirtualDisplay } from 'camoufox-js/dist/virtdisplay.js';
|
|
import { firefox } from 'playwright-core';
|
|
import express from 'express';
|
|
import crypto from 'crypto';
|
|
import fs from 'fs';
|
|
import os from 'os';
|
|
import { expandMacro } from './lib/macros.js';
|
|
import { loadConfig } from './lib/config.js';
|
|
import { normalizePlaywrightProxy, createProxyPool, buildProxyUrl } from './lib/proxy.js';
|
|
import { createFlyHelpers } from './lib/fly.js';
|
|
import { createPluginEvents, loadPlugins } from './lib/plugins.js';
|
|
import { requireAuth, accessKeyMiddleware, timingSafeCompare as _timingSafeCompare, isLoopbackAddress as _isLoopbackAddress } from './lib/auth.js';
|
|
import { windowSnapshot } from './lib/snapshot.js';
|
|
import {
|
|
MAX_DOWNLOAD_INLINE_BYTES,
|
|
clearTabDownloads,
|
|
clearSessionDownloads,
|
|
attachDownloadListener,
|
|
getDownloadsList,
|
|
} from './lib/downloads.js';
|
|
import { extractPageImages } from './lib/images.js';
|
|
import { extractDeterministic, validateSchema as validateExtractSchema } from './lib/extract.js';
|
|
import {
|
|
ensureTracesDir, resolveTracePath, tracePathFor, makeTraceFilename,
|
|
listUserTraces, statTrace, deleteTrace, sweepOldTraces,
|
|
} from './lib/tracing.js';
|
|
|
|
import {
|
|
initMetrics, getRegister, isMetricsEnabled, createMetric,
|
|
startMemoryReporter, stopMemoryReporter,
|
|
} from './lib/metrics.js';
|
|
import { actionFromReq, classifyError } from './lib/request-utils.js';
|
|
import { cleanupOrphanedTempFiles, cleanupStaleFirefoxProfiles } from './lib/tmp-cleanup.js';
|
|
import { coalesceInflight } from './lib/inflight.js';
|
|
import { createReporter, createTabHealthTracker, collectResourceSnapshot, classifyProxyError, browserProcessTreeRssMb } from './lib/reporter.js';
|
|
import { mountDocs } from './lib/openapi.js';
|
|
import { initSentry, captureException as sentryCaptureException, setupExpressErrorHandler as setupSentryErrorHandler, flush as sentryFlush } from './lib/sentry.js';
|
|
import { prepareExternalCamoufoxExecutable } from './lib/camoufox-executable.js';
|
|
|
|
const CONFIG = loadConfig();
|
|
|
|
// --- Crash reporter (opt-in, anonymized GitHub issues) ---
|
|
import { readFileSync } from 'fs';
|
|
const _pkgVersion = (() => { try { return JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version; } catch { return 'unknown'; } })();
|
|
|
|
// --- Sentry error tracking ---
|
|
initSentry({ ...CONFIG, version: _pkgVersion });
|
|
const reporter = createReporter({ ...CONFIG, version: _pkgVersion });
|
|
function _countTabs() {
|
|
let total = 0;
|
|
for (const session of sessions.values()) {
|
|
for (const group of session.tabGroups.values()) total += group.size;
|
|
}
|
|
return total;
|
|
}
|
|
function _browserPid() {
|
|
try { return browser?.process?.()?.pid ?? null; } catch { return null; }
|
|
}
|
|
function _resourceOpts() {
|
|
return { sessionCount: sessions.size, tabCount: _countTabs(), browserPid: _browserPid() };
|
|
}
|
|
reporter.startWatchdog(30_000, () => {
|
|
const summary = [];
|
|
for (const [sid, session] of sessions) {
|
|
const tabUrls = [];
|
|
for (const group of session.tabGroups.values()) {
|
|
for (const tab of group.values()) {
|
|
try {
|
|
const url = tab.page?.url?.() || 'unknown';
|
|
tabUrls.push(url);
|
|
} catch { tabUrls.push('error'); }
|
|
}
|
|
}
|
|
if (tabUrls.length > 0) summary.push({ session: sid, tabs: tabUrls.length, urls: tabUrls });
|
|
}
|
|
return { resourceOpts: _resourceOpts(), sessions: summary.length, summary };
|
|
});
|
|
|
|
// --- Plugin event bus ---
|
|
const pluginEvents = createPluginEvents();
|
|
|
|
// --- Shared auth middleware ---
|
|
const authMiddleware = () => requireAuth(CONFIG);
|
|
|
|
const {
|
|
requestsTotal, requestDuration, pageLoadDuration, snapshotBytes,
|
|
activeTabsGauge, tabLockQueueDepth,
|
|
tabLockTimeoutsTotal,
|
|
failuresTotal, browserRestartsTotal, tabsDestroyedTotal,
|
|
sessionsExpiredTotal, tabsReapedTotal, tabsRecycledTotal,
|
|
} = await initMetrics({ enabled: CONFIG.prometheusEnabled });
|
|
|
|
// --- Structured logging ---
|
|
function log(level, msg, fields = {}) {
|
|
const entry = {
|
|
ts: new Date().toISOString(),
|
|
level,
|
|
msg,
|
|
...fields,
|
|
};
|
|
const line = JSON.stringify(entry);
|
|
if (level === 'error') {
|
|
process.stderr.write(line + '\n');
|
|
} else {
|
|
process.stdout.write(line + '\n');
|
|
}
|
|
}
|
|
|
|
const app = express();
|
|
app.use(express.json({ limit: '100kb' }));
|
|
|
|
// Request logging + metrics middleware
|
|
app.use((req, res, next) => {
|
|
const reqId = crypto.randomUUID().slice(0, 8);
|
|
req.reqId = reqId;
|
|
req.startTime = Date.now();
|
|
|
|
const userId = req.body?.userId || req.query?.userId || '-';
|
|
if (req.path !== '/health') {
|
|
log('info', 'req', { reqId, method: req.method, path: req.path, userId });
|
|
}
|
|
|
|
const action = actionFromReq(req);
|
|
reporter.trackRoute(`${req.method} ${req.route?.path || '[unmatched]'}`);
|
|
const done = requestDuration.startTimer({ action });
|
|
|
|
const origEnd = res.end.bind(res);
|
|
res.end = function (...args) {
|
|
const ms = Date.now() - req.startTime;
|
|
const isErrorStatus = res.statusCode >= 400;
|
|
requestsTotal.labels(action, isErrorStatus ? 'error' : 'success').inc();
|
|
done();
|
|
|
|
if (req.path !== '/health') {
|
|
log('info', 'res', { reqId, status: res.statusCode, ms });
|
|
}
|
|
|
|
return origEnd(...args);
|
|
};
|
|
|
|
next();
|
|
});
|
|
|
|
// --- Horizontal scaling (Fly.io multi-machine) ---
|
|
const fly = createFlyHelpers(CONFIG);
|
|
const FLY_MACHINE_ID = fly.machineId;
|
|
|
|
// Route tab requests to the owning machine via fly-replay header.
|
|
app.use('/tabs/:tabId', fly.replayMiddleware(log));
|
|
|
|
// Access-key middleware: gates every route when CAMOFOX_ACCESS_KEY is set.
|
|
// Exempts /health (Docker healthcheck) and routes that have their own
|
|
// dedicated keys (cookie import -> CAMOFOX_API_KEY, /stop -> CAMOFOX_ADMIN_KEY)
|
|
// so each key gates a distinct surface. When unset, behavior is unchanged.
|
|
app.use(accessKeyMiddleware(CONFIG));
|
|
|
|
const ALLOWED_URL_SCHEMES = ['http:', 'https:'];
|
|
|
|
// Interactive roles to include - exclude combobox to avoid opening complex widgets
|
|
// (date pickers, dropdowns) that can interfere with navigation
|
|
const INTERACTIVE_ROLES = [
|
|
'button', 'link', 'textbox', 'checkbox', 'radio',
|
|
'menuitem', 'tab', 'searchbox', 'slider', 'spinbutton', 'switch'
|
|
// 'combobox' excluded - can trigger date pickers and complex dropdowns
|
|
];
|
|
|
|
// Patterns to skip (date pickers, calendar widgets -- NOT expiration/expiry fields)
|
|
const SKIP_PATTERNS = [
|
|
/datepicker/i, /date.?picker/i, /calendar/i, /^date$/i
|
|
];
|
|
|
|
// Iframe support: URL patterns to SKIP (tracking, analytics, pixels)
|
|
const IFRAME_SKIP_PATTERNS = [
|
|
/web-pixel/i, /analytics/i, /tracking/i, /gtm/i, /facebook/i,
|
|
/doubleclick/i, /google.*tag/i, /hotjar/i, /segment/i, /sentry/i,
|
|
/recaptcha/i, /gstatic/i, /app-bridge/i, /extensions\.shopifycdn/i,
|
|
];
|
|
const MAX_IFRAMES_TO_PROCESS = 8;
|
|
const IFRAME_SNAPSHOT_TIMEOUT_MS = 3000;
|
|
|
|
// timingSafeCompare and isLoopbackAddress imported from lib/auth.js
|
|
const timingSafeCompare = _timingSafeCompare;
|
|
const isLoopbackAddress = _isLoopbackAddress;
|
|
|
|
// Custom error for stale/unknown element refs -- returned as 422 instead of 500
|
|
class StaleRefsError extends Error {
|
|
constructor(ref, maxRef, totalRefs) {
|
|
super(`Unknown ref: ${ref} (valid refs: e1-${maxRef}, ${totalRefs} total). Refs reset after navigation - call snapshot first.`);
|
|
this.name = 'StaleRefsError';
|
|
this.code = 'stale_refs';
|
|
this.ref = ref;
|
|
}
|
|
}
|
|
|
|
function safeError(err) {
|
|
if (CONFIG.nodeEnv === 'production') {
|
|
log('error', 'internal error', { error: err.message, stack: err.stack });
|
|
return 'Internal server error';
|
|
}
|
|
return err.message;
|
|
}
|
|
|
|
// Send error response with appropriate status code (422 for stale refs, 500 otherwise)
|
|
function sendError(res, err, extraFields = {}) {
|
|
const status = err instanceof StaleRefsError ? 422 : (err.statusCode || 500);
|
|
const body = { error: safeError(err), ...extraFields };
|
|
if (err instanceof StaleRefsError) {
|
|
body.code = 'stale_refs';
|
|
body.ref = err.ref;
|
|
}
|
|
// Report unexpected 500s to Sentry (skip intentional admission-control 503s)
|
|
if (status >= 500 && !err.statusCode) {
|
|
sentryCaptureException(err, {
|
|
path: res.req?.originalUrl,
|
|
method: res.req?.method,
|
|
userId: res.req?.query?.userId || res.req?.body?.userId,
|
|
reqId: res.req?.reqId,
|
|
});
|
|
}
|
|
res.status(status).json(body);
|
|
}
|
|
|
|
function validateUrl(url) {
|
|
try {
|
|
const parsed = new URL(url);
|
|
if (!ALLOWED_URL_SCHEMES.includes(parsed.protocol)) {
|
|
return `Blocked URL scheme: ${parsed.protocol} (only http/https allowed)`;
|
|
}
|
|
return null;
|
|
} catch {
|
|
return `Invalid URL: ${url}`;
|
|
}
|
|
}
|
|
|
|
// isLoopbackAddress -- now imported from lib/auth.js (see top of file)
|
|
|
|
// Import cookies into a user's browser context (Playwright cookies format)
|
|
// POST /sessions/:userId/cookies { cookies: Cookie[] }
|
|
//
|
|
// SECURITY:
|
|
// Cookie injection moves this from "anonymous browsing" to "authenticated browsing".
|
|
/**
|
|
* @openapi
|
|
* /sessions/{userId}/cookies:
|
|
* post:
|
|
* tags: [Sessions]
|
|
* summary: Import cookies into a user session
|
|
* description: Import cookies for authenticated browsing. Requires BearerAuth in production.
|
|
* security:
|
|
* - BearerAuth: []
|
|
* parameters:
|
|
* - name: userId
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* description: Session owner identifier.
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [cookies]
|
|
* properties:
|
|
* cookies:
|
|
* type: array
|
|
* maxItems: 500
|
|
* items:
|
|
* type: object
|
|
* required: [name, value, domain]
|
|
* properties:
|
|
* name:
|
|
* type: string
|
|
* value:
|
|
* type: string
|
|
* domain:
|
|
* type: string
|
|
* path:
|
|
* type: string
|
|
* expires:
|
|
* type: number
|
|
* httpOnly:
|
|
* type: boolean
|
|
* secure:
|
|
* type: boolean
|
|
* sameSite:
|
|
* type: string
|
|
* enum: [Strict, Lax, None]
|
|
* responses:
|
|
* 200:
|
|
* description: Cookies imported.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* ok:
|
|
* type: boolean
|
|
* userId:
|
|
* type: string
|
|
* count:
|
|
* type: integer
|
|
* 400:
|
|
* description: Invalid cookie data.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 403:
|
|
* description: Forbidden.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
app.post('/sessions/:userId/cookies', authMiddleware(), express.json({ limit: '512kb' }), async (req, res) => {
|
|
try {
|
|
const userId = req.params.userId;
|
|
if (!req.body || !('cookies' in req.body)) {
|
|
return res.status(400).json({ error: 'Missing "cookies" field in request body' });
|
|
}
|
|
const cookies = req.body.cookies;
|
|
if (!Array.isArray(cookies)) {
|
|
return res.status(400).json({ error: 'cookies must be an array' });
|
|
}
|
|
|
|
if (cookies.length > 500) {
|
|
return res.status(400).json({ error: 'Too many cookies. Maximum 500 per request.' });
|
|
}
|
|
|
|
const invalid = [];
|
|
for (let i = 0; i < cookies.length; i++) {
|
|
const c = cookies[i];
|
|
const missing = [];
|
|
if (!c || typeof c !== 'object') {
|
|
invalid.push({ index: i, error: 'cookie must be an object' });
|
|
continue;
|
|
}
|
|
if (typeof c.name !== 'string' || !c.name) missing.push('name');
|
|
if (typeof c.value !== 'string') missing.push('value');
|
|
if (typeof c.domain !== 'string' || !c.domain) missing.push('domain');
|
|
if (missing.length) invalid.push({ index: i, missing });
|
|
}
|
|
if (invalid.length) {
|
|
return res.status(400).json({
|
|
error: 'Invalid cookie objects: each cookie must include name, value, and domain',
|
|
invalid,
|
|
});
|
|
}
|
|
|
|
const allowedFields = ['name', 'value', 'domain', 'path', 'expires', 'httpOnly', 'secure', 'sameSite'];
|
|
const sanitized = cookies.map(c => {
|
|
const clean = {};
|
|
for (const k of allowedFields) {
|
|
if (c[k] !== undefined) clean[k] = c[k];
|
|
}
|
|
return clean;
|
|
});
|
|
|
|
const session = await getSession(userId);
|
|
await session.context.addCookies(sanitized);
|
|
const result = { ok: true, userId: String(userId), count: sanitized.length };
|
|
log('info', 'cookies imported', { reqId: req.reqId, userId: String(userId), count: sanitized.length });
|
|
pluginEvents.emit('session:cookies:import', { userId: String(userId), count: sanitized.length });
|
|
res.json(result);
|
|
} catch (err) {
|
|
failuresTotal.labels(classifyError(err), 'set_cookies').inc();
|
|
log('error', 'cookie import failed', { reqId: req.reqId, error: err.message });
|
|
res.status(500).json({ error: safeError(err) });
|
|
}
|
|
});
|
|
|
|
let browser = null;
|
|
let _lastBrowserPid = null; // Track PID independently for force-kill after close
|
|
let _browserClosePromise = null; // Shared promise for concurrent close serialization
|
|
let _lastBrowserRestartAt = 0; // Timestamp of last browser relaunch (for stale tab detection)
|
|
// userId -> { context, tabGroups: Map<sessionKey, Map<tabId, TabState>>, lastAccess }
|
|
// TabState = { page, refs: Map<refId, {role, name, nth}>, visitedUrls: Set, downloads: Array, toolCalls: number }
|
|
// Note: sessionKey was previously called listItemId - both are accepted for backward compatibility
|
|
const sessions = new Map();
|
|
|
|
const SESSION_TIMEOUT_MS = CONFIG.sessionTimeoutMs;
|
|
const MAX_SNAPSHOT_NODES = 500;
|
|
const TAB_INACTIVITY_MS = CONFIG.tabInactivityMs;
|
|
const MAX_SESSIONS = CONFIG.maxSessions;
|
|
const MAX_TABS_PER_SESSION = CONFIG.maxTabsPerSession;
|
|
const MAX_TABS_GLOBAL = CONFIG.maxTabsGlobal;
|
|
const HANDLER_TIMEOUT_MS = CONFIG.handlerTimeoutMs;
|
|
const MAX_CONCURRENT_PER_USER = CONFIG.maxConcurrentPerUser;
|
|
const PAGE_CLOSE_TIMEOUT_MS = 5000;
|
|
const NAVIGATE_TIMEOUT_MS = CONFIG.navigateTimeoutMs;
|
|
const BUILDREFS_TIMEOUT_MS = CONFIG.buildrefsTimeoutMs;
|
|
const NATIVE_MEM_RESTART_THRESHOLD_MB = CONFIG.nativeMemRestartThresholdMb;
|
|
let _nativeMemBaseline = null; // RSS - heapUsed at first idle measurement
|
|
const FAILURE_THRESHOLD = 3;
|
|
const MAX_CONSECUTIVE_TIMEOUTS = 3;
|
|
const TAB_LOCK_TIMEOUT_MS = 35000; // Must be > HANDLER_TIMEOUT_MS so active op times out first
|
|
|
|
|
|
|
|
// Proper mutex for tab serialization. The old Promise-chain lock on timeout proceeded
|
|
// WITHOUT the lock, allowing concurrent Playwright operations that corrupt CDP state.
|
|
class TabLock {
|
|
constructor() {
|
|
this.queue = [];
|
|
this.active = false;
|
|
}
|
|
|
|
acquire(timeoutMs) {
|
|
return new Promise((resolve, reject) => {
|
|
const entry = { resolve, reject, timer: null };
|
|
entry.timer = setTimeout(() => {
|
|
const idx = this.queue.indexOf(entry);
|
|
if (idx !== -1) this.queue.splice(idx, 1);
|
|
tabLockTimeoutsTotal.inc();
|
|
refreshTabLockQueueDepth();
|
|
reject(new Error('Tab lock queue timeout'));
|
|
}, timeoutMs);
|
|
this.queue.push(entry);
|
|
refreshTabLockQueueDepth();
|
|
this._tryNext();
|
|
});
|
|
}
|
|
|
|
release() {
|
|
this.active = false;
|
|
this._tryNext();
|
|
refreshTabLockQueueDepth();
|
|
}
|
|
|
|
_tryNext() {
|
|
if (this.active || this.queue.length === 0) return;
|
|
this.active = true;
|
|
const entry = this.queue.shift();
|
|
clearTimeout(entry.timer);
|
|
refreshTabLockQueueDepth();
|
|
entry.resolve();
|
|
}
|
|
|
|
drain() {
|
|
this.active = true;
|
|
for (const entry of this.queue) {
|
|
clearTimeout(entry.timer);
|
|
entry.reject(new Error('Tab destroyed'));
|
|
}
|
|
this.queue = [];
|
|
refreshTabLockQueueDepth();
|
|
}
|
|
}
|
|
|
|
// Per-tab locks to serialize operations on the same tab
|
|
const tabLocks = new Map(); // tabId -> TabLock
|
|
|
|
function getTabLock(tabId) {
|
|
if (!tabLocks.has(tabId)) tabLocks.set(tabId, new TabLock());
|
|
return tabLocks.get(tabId);
|
|
}
|
|
|
|
// Timeout is INSIDE the lock so each operation gets its full budget
|
|
// regardless of how long it waited in the queue.
|
|
async function withTabLock(tabId, operation, timeoutMs = HANDLER_TIMEOUT_MS) {
|
|
const lock = getTabLock(tabId);
|
|
await lock.acquire(TAB_LOCK_TIMEOUT_MS);
|
|
try {
|
|
return await withTimeout(operation(), timeoutMs, 'action');
|
|
} finally {
|
|
lock.release();
|
|
}
|
|
}
|
|
|
|
function withTimeout(promise, ms, label) {
|
|
return Promise.race([
|
|
promise,
|
|
new Promise((_, reject) =>
|
|
setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)
|
|
)
|
|
]);
|
|
}
|
|
|
|
function requestTimeoutMs(baseMs = HANDLER_TIMEOUT_MS) {
|
|
return proxyPool?.canRotateSessions ? Math.max(baseMs, 180000) : baseMs;
|
|
}
|
|
|
|
const userConcurrency = new Map();
|
|
|
|
async function withUserLimit(userId, operation) {
|
|
const key = normalizeUserId(userId);
|
|
let state = userConcurrency.get(key);
|
|
if (!state) {
|
|
state = { active: 0, queue: [] };
|
|
userConcurrency.set(key, state);
|
|
}
|
|
if (state.active >= MAX_CONCURRENT_PER_USER) {
|
|
await new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => reject(new Error('User concurrency limit reached, try again')), 30000);
|
|
state.queue.push(() => { clearTimeout(timer); resolve(); });
|
|
});
|
|
}
|
|
state.active++;
|
|
healthState.activeOps++;
|
|
try {
|
|
const result = await operation();
|
|
healthState.lastSuccessfulNav = Date.now();
|
|
return result;
|
|
} finally {
|
|
healthState.activeOps--;
|
|
state.active--;
|
|
if (state.queue.length > 0) {
|
|
const next = state.queue.shift();
|
|
next();
|
|
}
|
|
if (state.active === 0 && state.queue.length === 0) {
|
|
userConcurrency.delete(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function safePageClose(page) {
|
|
if (!page || page.isClosed()) return;
|
|
try {
|
|
await Promise.race([
|
|
page.close({ runBeforeUnload: false }),
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error('page close timed out')), PAGE_CLOSE_TIMEOUT_MS)),
|
|
]);
|
|
} catch (e) {
|
|
log('warn', 'page close timed out or failed, force-closing', { error: e.message });
|
|
try { await page.close({ runBeforeUnload: false }); } catch (_) {}
|
|
page.removeAllListeners();
|
|
}
|
|
}
|
|
|
|
// Detect host OS for fingerprint generation
|
|
function getHostOS() {
|
|
const platform = os.platform();
|
|
if (platform === 'darwin') return 'macos';
|
|
if (platform === 'win32') return 'windows';
|
|
return 'linux';
|
|
}
|
|
|
|
// Proxy strategy for outbound browsing.
|
|
const proxyPool = createProxyPool(CONFIG.proxy);
|
|
|
|
if (proxyPool) {
|
|
log('info', 'proxy pool created', {
|
|
mode: proxyPool.mode,
|
|
host: proxyPool.canRotateSessions ? CONFIG.proxy.backconnectHost : CONFIG.proxy.host,
|
|
ports: proxyPool.canRotateSessions ? [CONFIG.proxy.backconnectPort] : CONFIG.proxy.ports,
|
|
poolSize: proxyPool.size,
|
|
country: CONFIG.proxy.country || null,
|
|
state: CONFIG.proxy.state || null,
|
|
city: CONFIG.proxy.city || null,
|
|
});
|
|
} else {
|
|
log('info', 'no proxy configured');
|
|
}
|
|
|
|
const BROWSER_IDLE_TIMEOUT_MS = CONFIG.browserIdleTimeoutMs;
|
|
let browserIdleTimer = null;
|
|
let browserLaunchPromise = null;
|
|
let browserWarmRetryTimer = null;
|
|
|
|
function scheduleBrowserIdleShutdown() {
|
|
if (browserIdleTimer || sessions.size > 0 || !browser) return;
|
|
browserIdleTimer = setTimeout(async () => {
|
|
browserIdleTimer = null;
|
|
if (sessions.size === 0 && browser) {
|
|
log('info', 'browser idle shutdown (no sessions)');
|
|
await closeBrowserFully('idle_shutdown');
|
|
}
|
|
}, BROWSER_IDLE_TIMEOUT_MS);
|
|
}
|
|
|
|
function clearBrowserIdleTimer() {
|
|
if (browserIdleTimer) {
|
|
clearTimeout(browserIdleTimer);
|
|
browserIdleTimer = null;
|
|
}
|
|
}
|
|
|
|
// Detects errors that retrying cannot recover from (e.g., Camoufox binary
|
|
// missing because postinstall was skipped). The user must run
|
|
// `npx camoufox-js fetch` and restart; looping on this wastes resources
|
|
// and buries the actionable error under noise.
|
|
//
|
|
// Sentinel: matches the human-readable message thrown by camoufox-js's
|
|
// FileNotFoundError in dist/pkgman.js (Version.fromPath). FileNotFoundError
|
|
// is not exported from the public API, so substring matching is the only
|
|
// available hook. If the upstream message changes, this regex needs an
|
|
// update; the dependency range in package.json controls exposure.
|
|
function isFatalInstallError(err) {
|
|
return /Version information not found/i.test(err?.message || '');
|
|
}
|
|
|
|
function camoufoxInstallRemediation() {
|
|
if (CONFIG.camoufoxExecutablePath) {
|
|
return 'verify CAMOUFOX_EXECUTABLE points to a Camoufox bundle with properties.json, version.json, and fontconfig/';
|
|
}
|
|
return 'run `npx camoufox-js fetch` then restart the server';
|
|
}
|
|
|
|
function scheduleBrowserWarmRetry(delayMs = 5000) {
|
|
if (browserWarmRetryTimer || browser || browserLaunchPromise) return;
|
|
browserWarmRetryTimer = setTimeout(async () => {
|
|
browserWarmRetryTimer = null;
|
|
try {
|
|
const start = Date.now();
|
|
await ensureBrowser();
|
|
log('info', 'background browser warm retry succeeded', { ms: Date.now() - start });
|
|
} catch (err) {
|
|
if (isFatalInstallError(err)) {
|
|
log('error', 'browser unavailable: Camoufox binaries are not installed; aborting retry loop', {
|
|
error: err.message,
|
|
remediation: camoufoxInstallRemediation(),
|
|
});
|
|
return;
|
|
}
|
|
log('warn', 'background browser warm retry failed', { error: err.message, nextDelayMs: delayMs });
|
|
scheduleBrowserWarmRetry(Math.min(delayMs * 2, 30000));
|
|
}
|
|
}, delayMs);
|
|
}
|
|
|
|
// --- Browser health tracking ---
|
|
const healthState = {
|
|
consecutiveNavFailures: 0,
|
|
lastSuccessfulNav: Date.now(),
|
|
isRecovering: false,
|
|
activeOps: 0,
|
|
};
|
|
|
|
function recordNavSuccess() {
|
|
healthState.consecutiveNavFailures = 0;
|
|
healthState.lastSuccessfulNav = Date.now();
|
|
}
|
|
|
|
function recordNavFailure() {
|
|
healthState.consecutiveNavFailures++;
|
|
return healthState.consecutiveNavFailures >= FAILURE_THRESHOLD;
|
|
}
|
|
|
|
async function restartBrowser(reason) {
|
|
if (healthState.isRecovering) return;
|
|
healthState.isRecovering = true;
|
|
browserRestartsTotal.labels(reason).inc();
|
|
log('error', 'restarting browser', { reason, failures: healthState.consecutiveNavFailures });
|
|
pluginEvents.emit('browser:restart', { reason });
|
|
try {
|
|
await closeAllSessions(`browser_restart:${reason}`, { clearDownloads: true, clearLocks: true });
|
|
await closeBrowserFully(`browser_restart:${reason}`);
|
|
pluginEvents.emit('browser:closed', { reason });
|
|
browserLaunchPromise = null;
|
|
await ensureBrowser();
|
|
healthState.consecutiveNavFailures = 0;
|
|
healthState.lastSuccessfulNav = Date.now();
|
|
log('info', 'browser restarted successfully');
|
|
} catch (err) {
|
|
log('error', 'browser restart failed', { error: err.message });
|
|
} finally {
|
|
healthState.isRecovering = false;
|
|
}
|
|
}
|
|
|
|
function getTotalTabCount() {
|
|
let total = 0;
|
|
for (const session of sessions.values()) {
|
|
try {
|
|
// Use real Playwright page count so leaked pages exert backpressure
|
|
// on MAX_TABS_GLOBAL, surfacing leaks before Firefox starves.
|
|
total += session.context.pages().length;
|
|
} catch (_) {
|
|
// Context is dead — fall back to bookkeeping count for this session.
|
|
for (const group of session.tabGroups.values()) total += group.size;
|
|
}
|
|
}
|
|
return total;
|
|
}
|
|
|
|
// Virtual display for WebGL support and anti-detection.
|
|
// Xvfb gives Firefox a real X display with GLX, enabling software-rendered WebGL
|
|
// via Mesa llvmpipe. Without this, WebGL returns "no context" -- a massive bot signal.
|
|
let virtualDisplay = null;
|
|
let browserLaunchProxy = null;
|
|
let externalCamoufoxLaunch = null;
|
|
|
|
function getExternalCamoufoxLaunch() {
|
|
if (!CONFIG.camoufoxExecutablePath) return null;
|
|
if (!externalCamoufoxLaunch) {
|
|
externalCamoufoxLaunch = prepareExternalCamoufoxExecutable(CONFIG.camoufoxExecutablePath, {
|
|
cacheDir: CONFIG.camoufoxCacheDir,
|
|
});
|
|
log('info', 'using external camoufox executable', {
|
|
executablePath: externalCamoufoxLaunch.executablePath,
|
|
resourceDir: externalCamoufoxLaunch.resourceDir,
|
|
});
|
|
}
|
|
return externalCamoufoxLaunch;
|
|
}
|
|
|
|
async function probeGoogleSearch(candidateBrowser) {
|
|
let context = null;
|
|
try {
|
|
context = await candidateBrowser.newContext({
|
|
viewport: { width: 1280, height: 720 },
|
|
permissions: ['geolocation'],
|
|
});
|
|
const page = await context.newPage();
|
|
await page.goto('https://www.google.com/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
await page.waitForTimeout(1200);
|
|
await page.goto('https://www.google.com/search?q=weather%20today', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
await page.waitForTimeout(4000);
|
|
|
|
const blocked = await isGoogleSearchBlocked(page);
|
|
return {
|
|
ok: !blocked && isGoogleSerp(page.url()),
|
|
url: page.url(),
|
|
blocked,
|
|
};
|
|
} finally {
|
|
await context?.close().catch(() => {});
|
|
}
|
|
}
|
|
|
|
function attachBrowserCleanup(candidateBrowser, localVirtualDisplay) {
|
|
const origClose = candidateBrowser.close.bind(candidateBrowser);
|
|
candidateBrowser.close = async (...args) => {
|
|
await origClose(...args);
|
|
browserLaunchProxy = null;
|
|
if (localVirtualDisplay) {
|
|
localVirtualDisplay.kill();
|
|
if (virtualDisplay === localVirtualDisplay) virtualDisplay = null;
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Close browser with full process-tree cleanup. Handles the race where
|
|
* browser.close() fails/hangs but process tree survives.
|
|
*
|
|
* Serialized: concurrent callers await the same promise (no double-close).
|
|
*
|
|
* Order: capture PID -> close browser -> force-kill survivors ->
|
|
* clean temp profiles -> verify FD/handle drop.
|
|
*/
|
|
async function closeBrowserFully(reason) {
|
|
if (_browserClosePromise) return _browserClosePromise;
|
|
_browserClosePromise = _closeBrowserFullyImpl(reason);
|
|
try {
|
|
return await _browserClosePromise;
|
|
} finally {
|
|
_browserClosePromise = null;
|
|
}
|
|
}
|
|
|
|
async function _closeBrowserFullyImpl(reason) {
|
|
const b = browser;
|
|
if (!b) return;
|
|
clearBrowserIdleTimer();
|
|
|
|
// Capture PID before nulling browser ref -- we need it for force-kill
|
|
const pid = _lastBrowserPid;
|
|
const preCloseFds = _countOpenFds();
|
|
const preCloseHandles = _countActiveHandles();
|
|
|
|
// Null the ref so new requests don't use a dying browser
|
|
browser = null;
|
|
_lastBrowserPid = null;
|
|
|
|
// Close through Playwright (sends CDP Browser.close, then SIGKILL process group)
|
|
let closeTimer;
|
|
try {
|
|
await Promise.race([
|
|
b.close(),
|
|
new Promise((_, reject) => { closeTimer = setTimeout(() => reject(new Error('browser.close() timeout')), 10000); }),
|
|
]);
|
|
} catch (err) {
|
|
log('warn', 'browser.close() failed or timed out', { reason, error: err.message, pid });
|
|
} finally {
|
|
clearTimeout(closeTimer);
|
|
}
|
|
|
|
// Force-kill browser survivors. Playwright's Firefox launcher can return no
|
|
// process PID, so fall back to scanning the container for Camoufox/Xvfb.
|
|
if (pid) {
|
|
await _forceKillProcessTree(pid, reason);
|
|
}
|
|
await _forceKillBrowserProcesses(reason, pid);
|
|
|
|
// Clean up stale Firefox temp profiles (enable_cache: true accumulates data)
|
|
try {
|
|
const cleaned = cleanupStaleFirefoxProfiles();
|
|
if (cleaned.removed > 0) {
|
|
log('info', 'cleaned stale firefox profiles after browser close', cleaned);
|
|
}
|
|
} catch { /* best effort */ }
|
|
|
|
// Reset native memory baseline so next browser measures from fresh
|
|
reporter.resetNativeMemBaseline();
|
|
_nativeMemBaseline = null;
|
|
|
|
// Verify cleanup: check FD/handle counts dropped (after force-kill completes)
|
|
const postCloseFds = _countOpenFds();
|
|
const postCloseHandles = _countActiveHandles();
|
|
if (postCloseFds !== null && preCloseFds !== null) {
|
|
const fdDelta = postCloseFds - preCloseFds;
|
|
// After close we expect fewer FDs. If more leaked, warn.
|
|
if (fdDelta > 10) {
|
|
log('warn', 'FD leak detected after browser close', {
|
|
reason, preCloseFds, postCloseFds, delta: fdDelta,
|
|
preCloseHandles, postCloseHandles,
|
|
});
|
|
}
|
|
}
|
|
log('info', 'browser closed fully', {
|
|
reason, pid, preCloseFds, postCloseFds, preCloseHandles, postCloseHandles,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Force-kill a browser process tree by PID. On Linux, kills the process group
|
|
* (SIGKILL -pid) then scans /proc for any orphaned children.
|
|
*/
|
|
async function _forceKillProcessTree(pid, reason) {
|
|
if (!pid || pid <= 1) return;
|
|
|
|
// Kill the specific browser process first (positive PID = single process)
|
|
try {
|
|
process.kill(pid, 'SIGKILL');
|
|
log('info', 'sent SIGKILL to browser process', { pid, reason });
|
|
} catch (err) {
|
|
if (err.code !== 'ESRCH') {
|
|
log('warn', 'failed to kill browser process', { pid, error: err.message });
|
|
}
|
|
}
|
|
|
|
// Then try the process group (Playwright launches with detached:true on Linux,
|
|
// making the browser a process group leader)
|
|
try {
|
|
process.kill(-pid, 'SIGKILL');
|
|
} catch {
|
|
// ESRCH = group doesn't exist (browser wasn't a group leader), which is fine
|
|
}
|
|
|
|
// Wait for kernel to reparent children to PID 1 before scanning
|
|
await new Promise(r => setTimeout(r, 200));
|
|
|
|
// On Linux: scan /proc for orphaned children that escaped the process group
|
|
// (reparented to PID 1 by init/systemd, common with Firefox content processes).
|
|
// Also checks PPid === Node PID for containerized environments without init.
|
|
if (process.platform === 'linux') {
|
|
const myPid = process.pid;
|
|
// Snapshot the current browser PID to avoid killing a newly launched browser
|
|
const currentBrowserPid = _lastBrowserPid;
|
|
try {
|
|
const procDirs = fs.readdirSync('/proc').filter(d => /^\d+$/.test(d));
|
|
const orphans = [];
|
|
for (const procPid of procDirs) {
|
|
const numPid = parseInt(procPid);
|
|
// Never kill ourselves, the old PID (already killed), or the new browser
|
|
if (numPid === myPid || numPid === pid || numPid === currentBrowserPid) continue;
|
|
try {
|
|
const status = fs.readFileSync(`/proc/${procPid}/status`, 'utf8');
|
|
const ppidMatch = status.match(/PPid:\s+(\d+)/);
|
|
const ppid = ppidMatch ? parseInt(ppidMatch[1]) : -1;
|
|
// Orphaned to init (PID 1) or reparented to us (Node is PID 1 in containers)
|
|
if (ppid === 1 || ppid === myPid) {
|
|
const cmdline = fs.readFileSync(`/proc/${procPid}/cmdline`, 'utf8');
|
|
// Firefox-specific: binary name or Gecko child process marker
|
|
if (/firefox-esr|firefox|camoufox|libxul\.so|GeckoChildProcess/i.test(cmdline)) {
|
|
orphans.push(numPid);
|
|
}
|
|
}
|
|
} catch { /* process vanished or permission denied */ }
|
|
}
|
|
if (orphans.length > 0) {
|
|
log('warn', 'killing orphaned browser child processes', { orphans, reason });
|
|
for (const orphanPid of orphans) {
|
|
try { process.kill(orphanPid, 'SIGKILL'); } catch { /* already dead */ }
|
|
}
|
|
}
|
|
} catch (err) {
|
|
log('warn', 'failed to scan for orphaned browser processes', { error: err.message });
|
|
}
|
|
}
|
|
|
|
// Give the OS a moment to reclaim resources
|
|
await new Promise(r => setTimeout(r, 300));
|
|
}
|
|
|
|
async function _forceKillBrowserProcesses(reason, excludePid = null) {
|
|
if (process.platform !== 'linux') return;
|
|
const myPid = process.pid;
|
|
const victims = [];
|
|
try {
|
|
const procDirs = fs.readdirSync('/proc').filter(d => /^\d+$/.test(d));
|
|
for (const procPid of procDirs) {
|
|
const numPid = parseInt(procPid);
|
|
if (numPid === myPid || numPid === excludePid) continue;
|
|
try {
|
|
const cmdline = fs.readFileSync(`/proc/${procPid}/cmdline`, 'utf8');
|
|
if (/camoufox-bin|\/usr\/bin\/Xvfb\b/.test(cmdline)) {
|
|
victims.push(numPid);
|
|
}
|
|
} catch { /* process vanished or permission denied */ }
|
|
}
|
|
} catch (err) {
|
|
log('warn', 'failed to scan for browser survivor processes', { reason, error: err.message });
|
|
return;
|
|
}
|
|
|
|
if (victims.length > 0) {
|
|
log('warn', 'killing browser survivor processes', { reason, victims });
|
|
for (const victimPid of victims) {
|
|
try { process.kill(victimPid, 'SIGKILL'); } catch { /* already dead */ }
|
|
}
|
|
await new Promise(r => setTimeout(r, 300));
|
|
}
|
|
}
|
|
|
|
function _countOpenFds() {
|
|
try {
|
|
if (process.platform === 'linux') return fs.readdirSync('/proc/self/fd').length;
|
|
} catch { /* unavailable */ }
|
|
return null;
|
|
}
|
|
|
|
function _countActiveHandles() {
|
|
try { return process._getActiveHandles().length; } catch { return null; }
|
|
}
|
|
|
|
async function launchBrowserInstance() {
|
|
const hostOS = getHostOS();
|
|
const maxAttempts = proxyPool?.launchRetries ?? 1;
|
|
let lastError = null;
|
|
const externalCamoufox = getExternalCamoufoxLaunch();
|
|
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
const launchProxy = proxyPool
|
|
? proxyPool.getLaunchProxy(proxyPool.canRotateSessions ? `browser-${crypto.randomUUID().replace(/-/g, '').slice(0, 12)}` : undefined)
|
|
: null;
|
|
|
|
let localVirtualDisplay = null;
|
|
let vdDisplay = undefined;
|
|
let candidateBrowser = null;
|
|
|
|
try {
|
|
if (os.platform() === 'linux') {
|
|
localVirtualDisplay = pluginCtx.createVirtualDisplay();
|
|
vdDisplay = localVirtualDisplay.get();
|
|
log('info', 'xvfb virtual display started', { display: vdDisplay, attempt });
|
|
}
|
|
} catch (err) {
|
|
log('warn', 'xvfb not available, falling back to headless', { error: err.message, attempt });
|
|
localVirtualDisplay = null;
|
|
}
|
|
|
|
const useVirtualDisplay = !!vdDisplay;
|
|
log('info', 'launching camoufox', {
|
|
hostOS,
|
|
attempt,
|
|
maxAttempts,
|
|
geoip: !!launchProxy,
|
|
proxyMode: proxyPool?.mode || null,
|
|
proxyServer: launchProxy?.server || null,
|
|
proxySession: launchProxy?.sessionId || null,
|
|
proxyPoolSize: proxyPool?.size || 0,
|
|
virtualDisplay: useVirtualDisplay,
|
|
});
|
|
|
|
try {
|
|
const options = await launchOptions({
|
|
executable_path: externalCamoufox?.executablePath,
|
|
headless: useVirtualDisplay ? false : true,
|
|
os: hostOS,
|
|
humanize: true,
|
|
enable_cache: true,
|
|
proxy: launchProxy,
|
|
geoip: !!launchProxy,
|
|
virtual_display: vdDisplay,
|
|
});
|
|
options.proxy = normalizePlaywrightProxy(options.proxy);
|
|
await pluginEvents.emitAsync('browser:launching', { options });
|
|
|
|
candidateBrowser = await firefox.launch(options);
|
|
|
|
if (proxyPool?.canRotateSessions) {
|
|
const probe = await probeGoogleSearch(candidateBrowser);
|
|
if (!probe.ok) {
|
|
log('warn', 'browser launch google probe failed', {
|
|
attempt,
|
|
maxAttempts,
|
|
proxySession: launchProxy?.sessionId || null,
|
|
url: probe.url,
|
|
});
|
|
if (attempt < maxAttempts) {
|
|
await candidateBrowser.close().catch(() => {});
|
|
if (localVirtualDisplay) localVirtualDisplay.kill();
|
|
continue;
|
|
}
|
|
// Last attempt: accept browser in degraded mode rather than death-spiraling.
|
|
// Non-Google sites will still work; Google requests will get blocked responses.
|
|
log('error', 'all proxy sessions Google-blocked, accepting browser in degraded mode', {
|
|
maxAttempts,
|
|
proxySession: launchProxy?.sessionId || null,
|
|
});
|
|
}
|
|
}
|
|
|
|
virtualDisplay = localVirtualDisplay;
|
|
browserLaunchProxy = launchProxy;
|
|
_lastBrowserPid = candidateBrowser.process?.()?.pid ?? null;
|
|
browser = candidateBrowser; // publish AFTER PID is captured
|
|
_lastBrowserRestartAt = Date.now();
|
|
attachBrowserCleanup(browser, localVirtualDisplay);
|
|
pluginEvents.emit('browser:launched', { browser, display: vdDisplay });
|
|
|
|
log('info', 'camoufox launched', {
|
|
attempt,
|
|
maxAttempts,
|
|
virtualDisplay: useVirtualDisplay,
|
|
proxyMode: proxyPool?.mode || null,
|
|
proxyServer: launchProxy?.server || null,
|
|
proxySession: launchProxy?.sessionId || null,
|
|
});
|
|
return browser;
|
|
} catch (err) {
|
|
lastError = err;
|
|
log('warn', 'camoufox launch attempt failed', {
|
|
attempt,
|
|
maxAttempts,
|
|
error: err.message,
|
|
proxySession: launchProxy?.sessionId || null,
|
|
});
|
|
await candidateBrowser?.close().catch(() => {});
|
|
if (localVirtualDisplay) localVirtualDisplay.kill();
|
|
}
|
|
}
|
|
|
|
throw lastError || new Error('Failed to launch a usable browser');
|
|
}
|
|
|
|
async function ensureBrowser() {
|
|
clearBrowserIdleTimer();
|
|
if (browser && !browser.isConnected()) {
|
|
failuresTotal.labels('browser_disconnected', 'internal').inc();
|
|
log('warn', 'browser disconnected, clearing dead sessions and relaunching', {
|
|
deadSessions: sessions.size,
|
|
});
|
|
await closeAllSessions('browser_disconnected', { clearDownloads: true, clearLocks: true });
|
|
await closeBrowserFully('browser_disconnected');
|
|
}
|
|
if (browser) return browser;
|
|
if (browserLaunchPromise) return browserLaunchPromise;
|
|
const launchTimeoutMs = proxyPool?.launchTimeoutMs ?? 60000;
|
|
browserLaunchPromise = Promise.race([
|
|
launchBrowserInstance(),
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error(`Browser launch timeout (${Math.round(launchTimeoutMs / 1000)}s)`)), launchTimeoutMs)),
|
|
]).finally(() => { browserLaunchPromise = null; });
|
|
return browserLaunchPromise;
|
|
}
|
|
|
|
// Helper to normalize userId to string (JSON body may parse as number)
|
|
function normalizeUserId(userId) {
|
|
return String(userId);
|
|
}
|
|
|
|
const sessionCreations = new Map();
|
|
|
|
function clearSessionLocks(session) {
|
|
if (!session?.tabGroups) return;
|
|
for (const [, group] of session.tabGroups) {
|
|
for (const tabId of group.keys()) {
|
|
const lock = tabLocks.get(tabId);
|
|
if (lock) {
|
|
lock.drain();
|
|
tabLocks.delete(tabId);
|
|
}
|
|
}
|
|
}
|
|
refreshTabLockQueueDepth();
|
|
}
|
|
|
|
async function closeSession(userId, session, {
|
|
reason = 'session_closed',
|
|
clearDownloads = true,
|
|
clearLocks = true,
|
|
} = {}) {
|
|
if (!session) return;
|
|
|
|
const key = normalizeUserId(userId);
|
|
|
|
// Drain locks BEFORE closing context — queued operations get clean "Tab destroyed"
|
|
// (410) instead of messy "Target page closed" (500) errors.
|
|
if (clearLocks) {
|
|
clearSessionLocks(session);
|
|
}
|
|
|
|
if (clearDownloads) {
|
|
await clearSessionDownloads(session).catch(() => {});
|
|
}
|
|
|
|
await pluginEvents.emitAsync('session:destroying', { userId: key, reason });
|
|
if (session.tracePath) {
|
|
try {
|
|
await session.context.tracing.stop({ path: session.tracePath });
|
|
log('info', 'tracing saved', { userId: key, path: session.tracePath });
|
|
} catch (err) {
|
|
log('warn', 'tracing.stop failed', { userId: key, error: err.message });
|
|
}
|
|
}
|
|
|
|
await session.context.close().catch(() => {});
|
|
sessions.delete(key);
|
|
await pluginEvents.emitAsync('session:destroyed', { userId: key, reason });
|
|
|
|
refreshActiveTabsGauge();
|
|
}
|
|
|
|
async function closeAllSessions(reason, { clearDownloads = true, clearLocks = true } = {}) {
|
|
const openSessions = Array.from(sessions.entries());
|
|
for (const [userId, session] of openSessions) {
|
|
await closeSession(userId, session, { reason, clearDownloads, clearLocks });
|
|
}
|
|
}
|
|
|
|
async function getSession(userId, { trace = false } = {}) {
|
|
const key = normalizeUserId(userId);
|
|
let session = sessions.get(key);
|
|
|
|
// Check if existing session's context is still alive
|
|
if (session) {
|
|
if (session._closing) {
|
|
// Session is being torn down by reaper/expiry -- treat as dead
|
|
session = null;
|
|
} else {
|
|
try {
|
|
// Lightweight probe: pages() is synchronous-ish and throws if context is dead
|
|
session.context.pages();
|
|
} catch (err) {
|
|
log('warn', 'session context dead, recreating', { userId: key, error: err.message });
|
|
await closeSession(key, session, { reason: 'dead_context', clearDownloads: true, clearLocks: true });
|
|
session = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!session) {
|
|
session = await coalesceInflight(sessionCreations, key, async () => {
|
|
if (sessions.size >= MAX_SESSIONS) {
|
|
throw Object.assign(
|
|
new Error('Maximum concurrent sessions reached'),
|
|
{ statusCode: 503, code: 'admission_rejected' }
|
|
);
|
|
}
|
|
// Memory admission control (Fly.io only) — reject new sessions when
|
|
// system memory is critically low. 503 tells Fly Proxy to try another machine.
|
|
if (FLY_MACHINE_ID) {
|
|
const freeMem = os.freemem();
|
|
const totalMem = os.totalmem();
|
|
if ((1 - freeMem / totalMem) >= 0.90) {
|
|
log('warn', 'memory admission rejected', {
|
|
usedPct: ((1 - freeMem / totalMem) * 100).toFixed(1),
|
|
freeMb: Math.round(freeMem / 1048576),
|
|
sessions: sessions.size,
|
|
});
|
|
throw Object.assign(
|
|
new Error('Server memory pressure — try again shortly'),
|
|
{ statusCode: 503, code: 'admission_rejected' }
|
|
);
|
|
}
|
|
}
|
|
const b = await ensureBrowser();
|
|
const contextOptions = {
|
|
viewport: { width: 1280, height: 720 },
|
|
permissions: ['geolocation'],
|
|
};
|
|
// When geoip is active (proxy configured), camoufox auto-configures
|
|
// locale/timezone/geolocation from the proxy IP. Without proxy, use defaults.
|
|
if (!CONFIG.proxy.host) {
|
|
contextOptions.locale = 'en-US';
|
|
contextOptions.timezoneId = 'America/Los_Angeles';
|
|
contextOptions.geolocation = { latitude: 37.7749, longitude: -122.4194 };
|
|
}
|
|
let sessionProxy = null;
|
|
if (proxyPool?.canRotateSessions) {
|
|
sessionProxy = proxyPool.getNext(`ctx-${key}-${crypto.randomUUID().replace(/-/g, '').slice(0, 8)}`);
|
|
contextOptions.proxy = normalizePlaywrightProxy(sessionProxy);
|
|
log('info', 'session proxy assigned', { userId: key, sessionId: sessionProxy.sessionId });
|
|
} else if (proxyPool) {
|
|
sessionProxy = proxyPool.getNext();
|
|
contextOptions.proxy = normalizePlaywrightProxy(sessionProxy);
|
|
log('info', 'session proxy assigned', { userId: key, proxy: sessionProxy.server });
|
|
}
|
|
await pluginEvents.emitAsync('session:creating', { userId: key, contextOptions });
|
|
const context = await b.newContext(contextOptions);
|
|
|
|
let tracePath = null;
|
|
if (trace) {
|
|
const traceDir = ensureTracesDir(CONFIG.tracesDir, key);
|
|
tracePath = tracePathFor(CONFIG.tracesDir, key, makeTraceFilename());
|
|
try {
|
|
await context.tracing.start({ screenshots: true, snapshots: true, sources: false });
|
|
log('info', 'tracing enabled for session', { userId: key, traceDir, tracePath });
|
|
} catch (err) {
|
|
log('warn', 'tracing.start failed; session will not be traced', { userId: key, error: err.message });
|
|
tracePath = null;
|
|
}
|
|
}
|
|
|
|
const created = { context, tabGroups: new Map(), lastAccess: Date.now(), proxySessionId: sessionProxy?.sessionId || null, tracePath };
|
|
sessions.set(key, created);
|
|
await pluginEvents.emitAsync('session:created', { userId: key, context });
|
|
log('info', 'session created', {
|
|
userId: key,
|
|
proxyMode: proxyPool?.mode || null,
|
|
proxyServer: sessionProxy?.server || browserLaunchProxy?.server || null,
|
|
proxySession: sessionProxy?.sessionId || browserLaunchProxy?.sessionId || null,
|
|
});
|
|
return created;
|
|
});
|
|
}
|
|
session.lastAccess = Date.now();
|
|
return session;
|
|
}
|
|
|
|
function getTabGroup(session, listItemId) {
|
|
let group = session.tabGroups.get(listItemId);
|
|
if (!group) {
|
|
group = new Map();
|
|
session.tabGroups.set(listItemId, group);
|
|
}
|
|
return group;
|
|
}
|
|
|
|
function isDeadContextError(err) {
|
|
const msg = err && err.message || '';
|
|
return msg.includes('Target page, context or browser has been closed') ||
|
|
msg.includes('browser has been closed') ||
|
|
msg.includes('Context closed') ||
|
|
msg.includes('Browser closed');
|
|
}
|
|
|
|
function isTimeoutError(err) {
|
|
const msg = err && err.message || '';
|
|
return msg.includes('timed out after') ||
|
|
(msg.includes('Timeout') && msg.includes('exceeded'));
|
|
}
|
|
|
|
function isTabLockQueueTimeout(err) {
|
|
return err && err.message === 'Tab lock queue timeout';
|
|
}
|
|
|
|
function isTabDestroyedError(err) {
|
|
return err && err.message === 'Tab destroyed';
|
|
}
|
|
|
|
// Centralized error handler for route catch blocks.
|
|
// Auto-destroys dead browser sessions and returns appropriate status codes.
|
|
function isProxyError(err) {
|
|
if (!err) return false;
|
|
const msg = err.message || '';
|
|
return msg.includes('NS_ERROR_PROXY') || msg.includes('proxy connection') || msg.includes('Proxy connection');
|
|
}
|
|
|
|
function handleRouteError(err, req, res, extraFields = {}) {
|
|
const failureType = classifyError(err);
|
|
const action = actionFromReq(req);
|
|
failuresTotal.labels(failureType, action).inc();
|
|
|
|
const userId = req.body?.userId || req.query?.userId;
|
|
const tabId = req.body?.tabId || req.query?.tabId || req.params?.tabId;
|
|
if (tabId) {
|
|
pluginEvents.emit('tab:error', { userId, tabId, error: err });
|
|
}
|
|
if (userId && isDeadContextError(err)) {
|
|
destroySession(userId);
|
|
}
|
|
// Proxy errors mean the session is dead -- rotate at context level.
|
|
// Destroy the user's session so the next request gets a fresh context with a new proxy.
|
|
if (isProxyError(err) && proxyPool?.canRotateSessions && userId) {
|
|
log('warn', 'proxy error detected, destroying user session for fresh proxy on next request', {
|
|
action, userId, error: err.message,
|
|
});
|
|
browserRestartsTotal.labels('proxy_error').inc();
|
|
destroySession(userId);
|
|
}
|
|
// Navigation-related timeouts can poison the proxy session (e.g., Cloudflare holding
|
|
// the connection open for 30s). The browser context shares a single proxy session, so
|
|
// one poisoned page kills all subsequent navigations in that context. Destroy the
|
|
// entire session so the next request gets a fresh BrowserContext + proxy.
|
|
const NAVIGATION_TIMEOUT_ACTIONS = new Set(['click', 'navigate', 'open_url']);
|
|
if (isTimeoutError(err) && userId && NAVIGATION_TIMEOUT_ACTIONS.has(action)) {
|
|
log('warn', 'navigation timeout — destroying session for fresh proxy', {
|
|
action, userId, error: err.message,
|
|
});
|
|
browserRestartsTotal.labels('navigation_timeout').inc();
|
|
destroySession(userId);
|
|
}
|
|
// Track consecutive timeouts per tab and auto-destroy stuck tabs
|
|
// (for non-navigation timeouts like type, scroll that don't poison the proxy)
|
|
if (userId && isTimeoutError(err) && !NAVIGATION_TIMEOUT_ACTIONS.has(action)) {
|
|
const tabId = req.body?.tabId || req.query?.tabId || req.params?.tabId;
|
|
const session = sessions.get(normalizeUserId(userId));
|
|
if (session && tabId) {
|
|
const found = findTab(session, tabId);
|
|
if (found) {
|
|
found.tabState.consecutiveTimeouts++;
|
|
if (found.tabState.consecutiveTimeouts >= MAX_CONSECUTIVE_TIMEOUTS) {
|
|
log('warn', 'auto-destroying tab after consecutive timeouts', { tabId, count: found.tabState.consecutiveTimeouts });
|
|
destroyTab(session, tabId, 'consecutive_timeouts', userId);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Lock queue timeout = tab is stuck. Destroy immediately.
|
|
if (userId && isTabLockQueueTimeout(err)) {
|
|
const session = sessions.get(normalizeUserId(userId));
|
|
if (session && tabId) {
|
|
destroyTab(session, tabId, 'lock_queue', userId);
|
|
}
|
|
return res.status(503).json({ error: 'Tab unresponsive and has been destroyed. Open a new tab.', code: 'tab_destroyed', ...extraFields });
|
|
}
|
|
// Tab was destroyed while this request was queued in the lock
|
|
if (isTabDestroyedError(err)) {
|
|
return res.status(410).json({ error: 'Tab was destroyed. Open a new tab.', ...extraFields });
|
|
}
|
|
// Dead context = session torn down (by proxy error, timeout, or reaper) while this op
|
|
// was in flight. The ROOT CAUSE was already reported — this is a cascade error.
|
|
// Return 503 (retriable) so the client retries with a fresh session.
|
|
if (isDeadContextError(err)) {
|
|
return res.status(503).json({ error: 'Browser session expired. Retry to get a fresh session.', code: 'session_expired', ...extraFields });
|
|
}
|
|
// --- Frustration detection: report when a tab hits a streak of failures ---
|
|
// Individual failures are noise. 3+ consecutive = the site is persistently broken.
|
|
const FRUSTRATION_TYPES = new Set(['timeout', 'dead_context', 'nav_aborted']);
|
|
if (FRUSTRATION_TYPES.has(failureType) && userId && tabId) {
|
|
const session = sessions.get(normalizeUserId(userId));
|
|
const found = session && findTab(session, tabId);
|
|
if (found) {
|
|
const ts = found.tabState;
|
|
ts.consecutiveFailures = (ts.consecutiveFailures || 0) + 1;
|
|
if (!ts.failureJournal) ts.failureJournal = [];
|
|
ts.failureJournal.push({ type: failureType, action, at: Date.now() });
|
|
if (ts.failureJournal.length > 20) ts.failureJournal = ts.failureJournal.slice(-20);
|
|
|
|
if (ts.consecutiveFailures === 3) {
|
|
const _proxyErr = classifyProxyError(err?.message);
|
|
reporter.reportHang(action, req.startTime ? Date.now() - req.startTime : 0, {
|
|
error: err,
|
|
healthSnapshot: ts.healthTracker ? ts.healthTracker.snapshot() : undefined,
|
|
healthTracker: ts.healthTracker || null,
|
|
resourceOpts: _resourceOpts(),
|
|
proxy: proxyPool ? {
|
|
configured: true,
|
|
type: proxyPool.mode || null,
|
|
authConfigured: !!CONFIG.proxy?.username,
|
|
error: _proxyErr.proxyError,
|
|
tlsError: _proxyErr.proxyTlsError,
|
|
} : { configured: false },
|
|
context: {
|
|
failureType,
|
|
consecutiveFailures: ts.consecutiveFailures,
|
|
toolCalls: ts.toolCalls,
|
|
journal: ts.failureJournal.map(j => `${j.type}:${j.action}`),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
sendError(res, err, extraFields);
|
|
}
|
|
|
|
function destroyTab(session, tabId, reason, userId) {
|
|
const lock = tabLocks.get(tabId);
|
|
if (lock) {
|
|
lock.drain();
|
|
tabLocks.delete(tabId);
|
|
refreshTabLockQueueDepth();
|
|
}
|
|
for (const [listItemId, group] of session.tabGroups) {
|
|
if (group.has(tabId)) {
|
|
const tabState = group.get(tabId);
|
|
log('warn', 'destroying stuck tab', { tabId, listItemId, toolCalls: tabState.toolCalls, reason: reason || 'unknown' });
|
|
safePageClose(tabState.page);
|
|
group.delete(tabId);
|
|
if (group.size === 0) session.tabGroups.delete(listItemId);
|
|
refreshActiveTabsGauge();
|
|
if (reason) tabsDestroyedTotal.labels(reason).inc();
|
|
pluginEvents.emit('tab:destroyed', { userId: userId || null, tabId, reason: reason || 'unknown' });
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Recycle the oldest (least-used) tab in a session to free a slot.
|
|
* Closes the old tab's page and removes it from its group.
|
|
* Returns { recycledTabId, recycledFromGroup } or null if no tab to recycle.
|
|
*/
|
|
async function recycleOldestTab(session, reqId, userId) {
|
|
let oldestTab = null;
|
|
let oldestGroup = null;
|
|
let oldestGroupKey = null;
|
|
let oldestTabId = null;
|
|
for (const [gKey, group] of session.tabGroups) {
|
|
for (const [tid, ts] of group) {
|
|
if (!oldestTab || ts.toolCalls < oldestTab.toolCalls) {
|
|
oldestTab = ts;
|
|
oldestGroup = group;
|
|
oldestGroupKey = gKey;
|
|
oldestTabId = tid;
|
|
}
|
|
}
|
|
}
|
|
if (!oldestTab) return null;
|
|
|
|
await safePageClose(oldestTab.page);
|
|
oldestGroup.delete(oldestTabId);
|
|
if (oldestGroup.size === 0) session.tabGroups.delete(oldestGroupKey);
|
|
const lock = tabLocks.get(oldestTabId);
|
|
if (lock) { lock.drain(); tabLocks.delete(oldestTabId); }
|
|
refreshTabLockQueueDepth();
|
|
tabsRecycledTotal.inc();
|
|
pluginEvents.emit('tab:recycled', { userId: userId || null, tabId: oldestTabId });
|
|
log('info', 'tab recycled (limit reached)', { reqId, recycledTabId: oldestTabId, recycledFromGroup: oldestGroupKey });
|
|
return { recycledTabId: oldestTabId, recycledFromGroup: oldestGroupKey };
|
|
}
|
|
|
|
function destroySession(userId) {
|
|
const key = normalizeUserId(userId);
|
|
const session = sessions.get(key);
|
|
if (!session) return;
|
|
log('warn', 'destroying dead session', { userId: key });
|
|
sessions.delete(key);
|
|
closeSession(key, session, { reason: 'destroy_session', clearDownloads: true, clearLocks: true }).catch(() => {});
|
|
}
|
|
|
|
function findTab(session, tabId) {
|
|
for (const [listItemId, group] of session.tabGroups) {
|
|
if (group.has(tabId)) {
|
|
const tabState = group.get(tabId);
|
|
return { tabState, listItemId, group };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Return 404 or 410 depending on whether the browser restarted recently.
|
|
// 410 Gone tells clients the tab existed but the browser crashed — create a new one.
|
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
|
|
function tabNotFoundResponse(res, tabId) {
|
|
// Only return 410 for tabs that look like valid UUIDs (plausibly created by this server),
|
|
// belonged to this machine, and were lost in a recent browser restart.
|
|
// Random/invalid strings like 'non-existent-tab' always get 404.
|
|
if (_lastBrowserRestartAt && (Date.now() - _lastBrowserRestartAt < 300_000) && UUID_RE.test(tabId) && fly.isLocalTab(tabId)) {
|
|
return res.status(410).json({
|
|
error: 'Tab no longer exists (browser was restarted). Create a new tab.',
|
|
code: 'browser_restarted',
|
|
});
|
|
}
|
|
return res.status(404).json({ error: 'Tab not found' });
|
|
}
|
|
|
|
function createTabState(page) {
|
|
const healthTracker = createTabHealthTracker(page);
|
|
return {
|
|
page,
|
|
refs: new Map(),
|
|
visitedUrls: new Set(),
|
|
downloads: [],
|
|
toolCalls: 0,
|
|
consecutiveTimeouts: 0,
|
|
consecutiveFailures: 0,
|
|
failureJournal: [],
|
|
healthTracker,
|
|
lastSnapshot: null,
|
|
lastRequestedUrl: null,
|
|
googleRetryCount: 0,
|
|
navigateAbort: null,
|
|
pressureObservedAt: Date.now(),
|
|
pressureObservedToolCalls: 0,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Attach a popup handler to a managed page so that popups (target=_blank,
|
|
* window.open) become tracked tabs rather than orphaned pages. (JO-2456)
|
|
*
|
|
* The handler registers the popup in the same session's '__popups__' tab group
|
|
* and recursively attaches itself to the new page.
|
|
*/
|
|
function attachPopupHandler(page, userId, sessionKey) {
|
|
page.on('popup', (popupPage) => {
|
|
const key = normalizeUserId(userId);
|
|
const currentSession = sessions.get(key);
|
|
if (!currentSession || currentSession._closing) return;
|
|
|
|
const popupTabId = fly.makeTabId();
|
|
const popupTabState = createTabState(popupPage);
|
|
attachDownloadListener(popupTabState, popupTabId, log, pluginEvents, key);
|
|
const popupGroup = getTabGroup(currentSession, sessionKey || '__popups__');
|
|
popupGroup.set(popupTabId, popupTabState);
|
|
currentSession.lastAccess = Date.now();
|
|
refreshActiveTabsGauge();
|
|
log('info', 'popup registered as managed tab', { userId: key, tabId: popupTabId, url: popupPage.url() });
|
|
pluginEvents.emit('tab:created', { userId: key, tabId: popupTabId, page: popupPage, url: popupPage.url() });
|
|
// Recursively handle popups from the popup
|
|
attachPopupHandler(popupPage, userId, sessionKey);
|
|
});
|
|
}
|
|
|
|
function pressureHash(value) {
|
|
return crypto.createHash('sha256').update(String(value)).digest('hex').slice(0, 12);
|
|
}
|
|
|
|
function pressureLockState(tabId) {
|
|
const lock = tabLocks.get(tabId);
|
|
return {
|
|
active: Boolean(lock?.active),
|
|
queued: Number(lock?.queue?.length || 0),
|
|
};
|
|
}
|
|
|
|
async function camofoxPressureCleanup(options = {}) {
|
|
const now = Date.now();
|
|
const minIdleMs = Math.max(0, Number(options.minIdleMs ?? 10 * 60 * 1000));
|
|
const maxTabsToClose = Math.max(0, Number(options.maxTabsToClose ?? 4));
|
|
const minTabsPerSession = Math.max(0, Number(options.minTabsPerSession ?? 1));
|
|
const dryRun = options.dryRun !== false;
|
|
const closeEmptySessions = options.closeEmptySessions !== false;
|
|
const before = { sessions: sessions.size, tabs: getTotalTabCount() };
|
|
const sessionTabCounts = new Map();
|
|
for (const [userId, session] of sessions) {
|
|
let count = 0;
|
|
for (const group of session.tabGroups.values()) count += group.size;
|
|
sessionTabCounts.set(userId, count);
|
|
}
|
|
const preserved = {
|
|
locked: 0,
|
|
session_minimum: 0,
|
|
first_observation: 0,
|
|
recently_active: 0,
|
|
below_min_idle: 0,
|
|
};
|
|
const candidates = [];
|
|
|
|
for (const [userId, session] of sessions) {
|
|
for (const [listItemId, group] of session.tabGroups) {
|
|
for (const [tabId, tabState] of group) {
|
|
const lockState = pressureLockState(tabId);
|
|
if (lockState.active || lockState.queued > 0) {
|
|
preserved.locked += 1;
|
|
continue;
|
|
}
|
|
|
|
if ((sessionTabCounts.get(userId) || 0) <= minTabsPerSession) {
|
|
preserved.session_minimum += 1;
|
|
continue;
|
|
}
|
|
|
|
if (!Number.isFinite(tabState.pressureObservedAt)) {
|
|
tabState.pressureObservedAt = now;
|
|
tabState.pressureObservedToolCalls = tabState.toolCalls;
|
|
preserved.first_observation += 1;
|
|
continue;
|
|
}
|
|
|
|
if (tabState.pressureObservedToolCalls !== tabState.toolCalls) {
|
|
tabState.pressureObservedAt = now;
|
|
tabState.pressureObservedToolCalls = tabState.toolCalls;
|
|
preserved.recently_active += 1;
|
|
continue;
|
|
}
|
|
|
|
const idleMs = now - tabState.pressureObservedAt;
|
|
if (idleMs < minIdleMs) {
|
|
preserved.below_min_idle += 1;
|
|
continue;
|
|
}
|
|
|
|
candidates.push({
|
|
userId,
|
|
session,
|
|
listItemId,
|
|
group,
|
|
tabId,
|
|
tabState,
|
|
idleMs,
|
|
toolCalls: tabState.toolCalls,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
candidates.sort((a, b) => (b.idleMs - a.idleMs) || (a.toolCalls - b.toolCalls));
|
|
const selected = candidates.slice(0, maxTabsToClose);
|
|
const selectedSummary = selected.map((item) => ({
|
|
session: pressureHash(item.userId),
|
|
tab: pressureHash(item.tabId),
|
|
group: pressureHash(item.listItemId),
|
|
idleMs: item.idleMs,
|
|
toolCalls: item.toolCalls,
|
|
}));
|
|
const closed = [];
|
|
|
|
if (!dryRun) {
|
|
for (const item of selected) {
|
|
if (!item.group.has(item.tabId)) continue;
|
|
if ((sessionTabCounts.get(item.userId) || 0) <= minTabsPerSession) continue;
|
|
const lockState = pressureLockState(item.tabId);
|
|
if (lockState.active || lockState.queued > 0) continue;
|
|
if (item.tabState.navigateAbort) item.tabState.navigateAbort.abort();
|
|
await clearTabDownloads(item.tabState).catch(() => {});
|
|
await safePageClose(item.tabState.page);
|
|
item.group.delete(item.tabId);
|
|
sessionTabCounts.set(item.userId, Math.max(0, (sessionTabCounts.get(item.userId) || 0) - 1));
|
|
const lock = tabLocks.get(item.tabId);
|
|
if (lock) {
|
|
lock.drain();
|
|
tabLocks.delete(item.tabId);
|
|
}
|
|
tabsReapedTotal.inc();
|
|
pluginEvents.emit('tab:reaped', { userId: item.userId, tabId: item.tabId, listItemId: item.listItemId, reason: 'pressure_cleanup', idleMs: item.idleMs });
|
|
log('info', 'tab reaped (pressure cleanup)', { userId: item.userId, tabId: item.tabId, listItemId: item.listItemId, idleMs: item.idleMs, toolCalls: item.toolCalls });
|
|
closed.push({ session: pressureHash(item.userId), tab: pressureHash(item.tabId), group: pressureHash(item.listItemId), idleMs: item.idleMs, toolCalls: item.toolCalls });
|
|
}
|
|
|
|
for (const [userId, session] of Array.from(sessions.entries())) {
|
|
for (const [listItemId, group] of Array.from(session.tabGroups.entries())) {
|
|
if (group.size === 0) session.tabGroups.delete(listItemId);
|
|
}
|
|
if (closeEmptySessions && session.tabGroups.size === 0) {
|
|
session._closing = true;
|
|
await closeSession(userId, session, { reason: 'pressure_cleanup_empty_session', clearDownloads: true, clearLocks: true });
|
|
sessionsExpiredTotal.inc();
|
|
log('info', 'session closed (pressure cleanup empty)', { userId });
|
|
}
|
|
}
|
|
|
|
refreshTabLockQueueDepth();
|
|
refreshActiveTabsGauge();
|
|
if (sessions.size === 0) scheduleBrowserIdleShutdown();
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
dryRun,
|
|
minIdleMs,
|
|
maxTabsToClose,
|
|
minTabsPerSession,
|
|
before,
|
|
candidates: candidates.length,
|
|
selected: selectedSummary,
|
|
closed,
|
|
preserved,
|
|
after: { sessions: sessions.size, tabs: getTotalTabCount() },
|
|
};
|
|
}
|
|
|
|
async function isGoogleUnavailable(page) {
|
|
if (!page || page.isClosed()) return false;
|
|
const bodyText = await page.evaluate(() => document.body?.innerText?.slice(0, 600) || '').catch(() => '');
|
|
return /Unable to connect|502 Bad Gateway or Proxy Error|Camoufox can't establish a connection/.test(bodyText);
|
|
}
|
|
|
|
async function rotateGoogleTab(userId, sessionKey, tabId, previousTabState, reason, reqId) {
|
|
if (!previousTabState?.lastRequestedUrl || !isGoogleSearchUrl(previousTabState.lastRequestedUrl)) return null;
|
|
if ((previousTabState.googleRetryCount || 0) >= 3) return null;
|
|
|
|
browserRestartsTotal.labels(reason).inc(); // track rotation events (not a full restart)
|
|
|
|
// Rotate at context level -- create a fresh context with a new proxy session
|
|
// instead of restarting the entire browser (which kills ALL sessions/tabs).
|
|
const key = normalizeUserId(userId);
|
|
const oldSession = sessions.get(key);
|
|
if (oldSession) {
|
|
await closeSession(key, oldSession, { reason: 'google_rotate_context', clearDownloads: true, clearLocks: true });
|
|
}
|
|
const session = await getSession(userId);
|
|
const group = getTabGroup(session, sessionKey);
|
|
const page = await session.context.newPage();
|
|
const tabState = createTabState(page);
|
|
tabState.googleRetryCount = (previousTabState.googleRetryCount || 0) + 1;
|
|
tabState.lastRequestedUrl = previousTabState.lastRequestedUrl;
|
|
attachDownloadListener(tabState, tabId, log, pluginEvents, userId);
|
|
group.set(tabId, tabState);
|
|
attachPopupHandler(page, userId, sessionKey);
|
|
refreshActiveTabsGauge();
|
|
|
|
log('warn', 'replaying google search on fresh context (per-context proxy rotation)', {
|
|
reqId,
|
|
tabId,
|
|
retryCount: tabState.googleRetryCount,
|
|
url: tabState.lastRequestedUrl,
|
|
proxySession: session.proxySessionId || null,
|
|
});
|
|
|
|
await withPageLoadDuration('navigate', () => page.goto('https://www.google.com/', { waitUntil: 'domcontentloaded', timeout: 30000 }));
|
|
tabState.visitedUrls.add('https://www.google.com/');
|
|
await page.waitForTimeout(1200);
|
|
await withPageLoadDuration('navigate', () => page.goto(tabState.lastRequestedUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }));
|
|
tabState.visitedUrls.add(tabState.lastRequestedUrl);
|
|
return { session, tabState };
|
|
}
|
|
|
|
function refreshActiveTabsGauge() {
|
|
activeTabsGauge.set(getTotalTabCount());
|
|
}
|
|
|
|
function refreshTabLockQueueDepth() {
|
|
let queued = 0;
|
|
for (const lock of tabLocks.values()) {
|
|
if (lock?.queue) queued += lock.queue.length;
|
|
}
|
|
tabLockQueueDepth.set(queued);
|
|
}
|
|
|
|
async function withPageLoadDuration(action, fn) {
|
|
const end = pageLoadDuration.startTimer();
|
|
try {
|
|
return await fn();
|
|
} finally {
|
|
end();
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function waitForPageReady(page, options = {}) {
|
|
const {
|
|
timeout = 10000,
|
|
waitForNetwork = true,
|
|
waitForHydration = true,
|
|
settleMs = 200,
|
|
hydrationPollMs = 250,
|
|
hydrationTimeoutMs = Math.min(timeout, 10000),
|
|
} = options;
|
|
|
|
try {
|
|
await page.waitForLoadState('domcontentloaded', { timeout });
|
|
|
|
if (waitForNetwork) {
|
|
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {
|
|
log('warn', 'networkidle timeout, continuing');
|
|
});
|
|
}
|
|
|
|
if (waitForHydration) {
|
|
const maxIterations = Math.max(1, Math.floor(hydrationTimeoutMs / hydrationPollMs));
|
|
await page.evaluate(async ({ maxIterations, hydrationPollMs }) => {
|
|
for (let i = 0; i < maxIterations; i++) {
|
|
const entries = performance.getEntriesByType('resource');
|
|
const recentEntries = entries.slice(-5);
|
|
const netQuiet = recentEntries.every(e => (performance.now() - e.responseEnd) > 400);
|
|
|
|
if (document.readyState === 'complete' && netQuiet) {
|
|
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
|
break;
|
|
}
|
|
await new Promise(r => setTimeout(r, hydrationPollMs));
|
|
}
|
|
}, { maxIterations, hydrationPollMs }).catch(() => {
|
|
log('warn', 'hydration wait failed, continuing');
|
|
});
|
|
}
|
|
|
|
if (settleMs > 0) {
|
|
await page.waitForTimeout(settleMs);
|
|
}
|
|
|
|
// Auto-dismiss common consent/privacy dialogs
|
|
await dismissConsentDialogs(page);
|
|
|
|
return true;
|
|
} catch (err) {
|
|
log('warn', 'page ready failed', { error: err.message });
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function dismissConsentDialogs(page) {
|
|
// Common consent/privacy dialog selectors (matches Swift WebView.swift patterns)
|
|
const dismissSelectors = [
|
|
// OneTrust (very common)
|
|
'#onetrust-banner-sdk button#onetrust-accept-btn-handler',
|
|
'#onetrust-banner-sdk button#onetrust-reject-all-handler',
|
|
'#onetrust-close-btn-container button',
|
|
// Generic patterns
|
|
'button[data-test="cookie-accept-all"]',
|
|
'button[aria-label="Accept all"]',
|
|
'button[aria-label="Accept All"]',
|
|
'button[aria-label="Close"]',
|
|
'button[aria-label="Dismiss"]',
|
|
// Dialog close buttons
|
|
'dialog button:has-text("Close")',
|
|
'dialog button:has-text("Accept")',
|
|
'dialog button:has-text("I Accept")',
|
|
'dialog button:has-text("Got it")',
|
|
'dialog button:has-text("OK")',
|
|
// GDPR/CCPA specific
|
|
'[class*="consent"] button[class*="accept"]',
|
|
'[class*="consent"] button[class*="close"]',
|
|
'[class*="privacy"] button[class*="close"]',
|
|
'[class*="cookie"] button[class*="accept"]',
|
|
'[class*="cookie"] button[class*="close"]',
|
|
// Overlay close buttons
|
|
'[class*="modal"] button[class*="close"]',
|
|
'[class*="overlay"] button[class*="close"]',
|
|
];
|
|
|
|
for (const selector of dismissSelectors) {
|
|
try {
|
|
const button = page.locator(selector).first();
|
|
if (await button.isVisible({ timeout: 100 })) {
|
|
await button.click({ timeout: 1000 }).catch(() => {});
|
|
log('info', 'dismissed consent dialog', { selector });
|
|
await page.waitForTimeout(300); // Brief pause after dismiss
|
|
break; // Only dismiss one dialog per page load
|
|
}
|
|
} catch (e) {
|
|
// Selector not found or not clickable, continue
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Google SERP detection ---
|
|
function isGoogleSerp(url) {
|
|
try {
|
|
const parsed = new URL(url);
|
|
return parsed.hostname.includes('google.') && parsed.pathname === '/search';
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function isGoogleSearchUrl(url) {
|
|
try {
|
|
const parsed = new URL(url);
|
|
return parsed.hostname.includes('google.') && parsed.pathname === '/search';
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function isGoogleSearchBlocked(page) {
|
|
if (!page || page.isClosed()) return false;
|
|
|
|
const url = page.url();
|
|
if (url.includes('google.com/sorry/')) return true;
|
|
|
|
const bodyText = await page.evaluate(() => document.body?.innerText?.slice(0, 600) || '').catch(() => '');
|
|
return /Our systems have detected unusual traffic|About this page|If you're having trouble accessing Google Search|SG_REL/.test(bodyText);
|
|
}
|
|
|
|
// --- Google SERP: combined extraction (refs + snapshot in one DOM pass) ---
|
|
// Returns { refs: Map, snapshot: string }
|
|
async function extractGoogleSerp(page) {
|
|
const refs = new Map();
|
|
if (!page || page.isClosed()) return { refs, snapshot: '' };
|
|
|
|
const start = Date.now();
|
|
|
|
const alreadyRendered = await page.evaluate(() => !!document.querySelector('#rso h3, #search h3, #rso [data-snhf]')).catch(() => false);
|
|
if (!alreadyRendered) {
|
|
try {
|
|
await page.waitForSelector('#rso h3, #search h3, #rso [data-snhf]', { timeout: 5000 });
|
|
} catch {
|
|
try {
|
|
await page.waitForSelector('#rso a[href]:not([href^="/search"]), #search a[href]:not([href^="/search"])', { timeout: 2000 });
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
const extracted = await page.evaluate(() => {
|
|
const snapshot = [];
|
|
const elements = [];
|
|
let refCounter = 1;
|
|
|
|
function addRef(role, name) {
|
|
const id = 'e' + refCounter++;
|
|
elements.push({ id, role, name });
|
|
return id;
|
|
}
|
|
|
|
snapshot.push('- heading "' + document.title.replace(/"/g, '\\"') + '"');
|
|
|
|
const searchInput = document.querySelector('input[name="q"], textarea[name="q"]');
|
|
if (searchInput) {
|
|
const name = 'Search';
|
|
const refId = addRef('searchbox', name);
|
|
snapshot.push('- searchbox "' + name + '" [' + refId + ']: ' + (searchInput.value || ''));
|
|
}
|
|
|
|
const navContainer = document.querySelector('div[role="navigation"], div[role="list"]');
|
|
if (navContainer) {
|
|
const navLinks = navContainer.querySelectorAll('a');
|
|
if (navLinks.length > 0) {
|
|
snapshot.push('- navigation:');
|
|
navLinks.forEach(a => {
|
|
const text = (a.textContent || '').trim();
|
|
if (!text || text.length < 1) return;
|
|
if (/^\d+$/.test(text) && parseInt(text) < 50) return;
|
|
const refId = addRef('link', text);
|
|
snapshot.push(' - link "' + text + '" [' + refId + ']');
|
|
});
|
|
}
|
|
}
|
|
|
|
const resultContainer = document.querySelector('#rso') || document.querySelector('#search');
|
|
if (resultContainer) {
|
|
const resultBlocks = resultContainer.querySelectorAll(':scope > div');
|
|
for (const block of resultBlocks) {
|
|
const h3 = block.querySelector('h3');
|
|
const mainLink = h3 ? h3.closest('a') : null;
|
|
|
|
if (h3 && mainLink) {
|
|
const title = h3.textContent.trim().replace(/"/g, '\\"');
|
|
const href = mainLink.href;
|
|
const cite = block.querySelector('cite');
|
|
const displayUrl = cite ? cite.textContent.trim() : '';
|
|
|
|
let snippet = '';
|
|
for (const sel of ['[data-sncf]', '[data-content-feature="1"]', '.VwiC3b', 'div[style*="-webkit-line-clamp"]', 'span.aCOpRe']) {
|
|
const el = block.querySelector(sel);
|
|
if (el) { snippet = el.textContent.trim().slice(0, 300); break; }
|
|
}
|
|
if (!snippet) {
|
|
const allText = block.textContent.trim().replace(/\s+/g, ' ');
|
|
const titleLen = title.length + (displayUrl ? displayUrl.length : 0);
|
|
if (allText.length > titleLen + 20) {
|
|
snippet = allText.slice(titleLen).trim().slice(0, 300);
|
|
}
|
|
}
|
|
|
|
const refId = addRef('link', title);
|
|
snapshot.push('- link "' + title + '" [' + refId + ']:');
|
|
snapshot.push(' - /url: ' + href);
|
|
if (displayUrl) snapshot.push(' - cite: ' + displayUrl);
|
|
if (snippet) snapshot.push(' - text: ' + snippet);
|
|
} else {
|
|
const blockLinks = block.querySelectorAll('a[href^="http"]:not([href*="google.com/search"])');
|
|
if (blockLinks.length > 0) {
|
|
const blockText = block.textContent.trim().replace(/\s+/g, ' ').slice(0, 200);
|
|
if (blockText.length > 10) {
|
|
snapshot.push('- group:');
|
|
snapshot.push(' - text: ' + blockText);
|
|
blockLinks.forEach(a => {
|
|
const linkText = (a.textContent || '').trim().replace(/"/g, '\\"').slice(0, 100);
|
|
if (linkText.length > 2) {
|
|
const refId = addRef('link', linkText);
|
|
snapshot.push(' - link "' + linkText + '" [' + refId + ']:');
|
|
snapshot.push(' - /url: ' + a.href);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const paaItems = document.querySelectorAll('[jsname="Cpkphb"], div.related-question-pair');
|
|
if (paaItems.length > 0) {
|
|
snapshot.push('- heading "People also ask"');
|
|
paaItems.forEach(q => {
|
|
const text = (q.textContent || '').trim().replace(/"/g, '\\"').slice(0, 150);
|
|
if (text) {
|
|
const refId = addRef('button', text);
|
|
snapshot.push(' - button "' + text + '" [' + refId + ']');
|
|
}
|
|
});
|
|
}
|
|
|
|
const nextLink = document.querySelector('#botstuff a[aria-label="Next page"], td.d6cvqb a, a#pnnext');
|
|
if (nextLink) {
|
|
const refId = addRef('link', 'Next');
|
|
snapshot.push('- navigation "pagination":');
|
|
snapshot.push(' - link "Next" [' + refId + ']');
|
|
}
|
|
|
|
return { snapshot: snapshot.join('\n'), elements };
|
|
});
|
|
|
|
const seenCounts = new Map();
|
|
for (const el of extracted.elements) {
|
|
const key = `${el.role}:${el.name}`;
|
|
const nth = seenCounts.get(key) || 0;
|
|
seenCounts.set(key, nth + 1);
|
|
refs.set(el.id, { role: el.role, name: el.name, nth });
|
|
}
|
|
|
|
log('info', 'extractGoogleSerp', { elapsed: Date.now() - start, refs: refs.size });
|
|
return { refs, snapshot: extracted.snapshot };
|
|
}
|
|
|
|
const REFRESH_READY_TIMEOUT_MS = 2500;
|
|
|
|
async function buildRefs(page) {
|
|
const refs = new Map();
|
|
|
|
if (!page || page.isClosed()) {
|
|
log('warn', 'buildRefs: page closed or invalid');
|
|
return refs;
|
|
}
|
|
|
|
// Google SERP fast path -- skip ariaSnapshot entirely
|
|
const url = page.url();
|
|
if (isGoogleSerp(url)) {
|
|
const { refs: googleRefs } = await extractGoogleSerp(page);
|
|
return googleRefs;
|
|
}
|
|
|
|
const start = Date.now();
|
|
|
|
// Hard total timeout on the entire buildRefs operation
|
|
let timerId;
|
|
const timeoutPromise = new Promise((_, reject) => {
|
|
timerId = setTimeout(() => reject(new Error('buildRefs_timeout')), BUILDREFS_TIMEOUT_MS);
|
|
});
|
|
|
|
try {
|
|
const result = await Promise.race([
|
|
_buildRefsInner(page, refs, start),
|
|
timeoutPromise
|
|
]);
|
|
clearTimeout(timerId);
|
|
return result;
|
|
} catch (err) {
|
|
clearTimeout(timerId);
|
|
if (err.message === 'buildRefs_timeout') {
|
|
log('warn', 'buildRefs: total timeout exceeded', { elapsed: Date.now() - start });
|
|
return refs;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async function _buildRefsInner(page, refs, start) {
|
|
await waitForPageReady(page, {
|
|
timeout: REFRESH_READY_TIMEOUT_MS,
|
|
waitForNetwork: false,
|
|
waitForHydration: false,
|
|
settleMs: 100,
|
|
});
|
|
|
|
// Budget remaining time for ariaSnapshot
|
|
const elapsed = Date.now() - start;
|
|
const remaining = BUILDREFS_TIMEOUT_MS - elapsed;
|
|
if (remaining < 2000) {
|
|
log('warn', 'buildRefs: insufficient time for ariaSnapshot', { elapsed });
|
|
return refs;
|
|
}
|
|
|
|
let ariaYaml;
|
|
try {
|
|
ariaYaml = await page.locator('body').ariaSnapshot({ timeout: Math.min(remaining - 1000, 5000) });
|
|
} catch (err) {
|
|
log('warn', 'ariaSnapshot failed, retrying');
|
|
const retryBudget = BUILDREFS_TIMEOUT_MS - (Date.now() - start);
|
|
if (retryBudget < 2000) return refs;
|
|
try {
|
|
ariaYaml = await page.locator('body').ariaSnapshot({ timeout: Math.min(retryBudget - 500, 5000) });
|
|
} catch (retryErr) {
|
|
log('warn', 'ariaSnapshot retry failed, returning empty refs', { error: retryErr.message });
|
|
return refs;
|
|
}
|
|
}
|
|
|
|
if (!ariaYaml) {
|
|
log('warn', 'buildRefs: no aria snapshot');
|
|
return refs;
|
|
}
|
|
|
|
const lines = ariaYaml.split('\n');
|
|
let refCounter = 1;
|
|
|
|
// Track occurrences of each role+name combo for nth disambiguation
|
|
const seenCounts = new Map(); // "role:name" -> count
|
|
|
|
for (const line of lines) {
|
|
if (refCounter > MAX_SNAPSHOT_NODES) break;
|
|
|
|
const match = line.match(/^\s*-\s+(\w+)(?:\s+"([^"]*)")?/);
|
|
if (match) {
|
|
const [, role, name] = match;
|
|
const normalizedRole = role.toLowerCase();
|
|
|
|
if (normalizedRole === 'combobox') continue;
|
|
|
|
if (name && SKIP_PATTERNS.some(p => p.test(name))) continue;
|
|
|
|
if (INTERACTIVE_ROLES.includes(normalizedRole)) {
|
|
const normalizedName = name || '';
|
|
const key = `${normalizedRole}:${normalizedName}`;
|
|
|
|
// Get current count and increment
|
|
const nth = seenCounts.get(key) || 0;
|
|
seenCounts.set(key, nth + 1);
|
|
|
|
const refId = `e${refCounter++}`;
|
|
refs.set(refId, { role: normalizedRole, name: normalizedName, nth });
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- IFRAME SUPPORT ---
|
|
// Process child frames to capture elements inside iframes (e.g., Stripe payment fields)
|
|
const iframeRemaining = BUILDREFS_TIMEOUT_MS - (Date.now() - start);
|
|
if (iframeRemaining > 2000 && refCounter <= MAX_SNAPSHOT_NODES) {
|
|
const childFrames = page.frames().filter(f => f !== page.mainFrame());
|
|
let iframesProcessed = 0;
|
|
|
|
for (const frame of childFrames) {
|
|
if (iframesProcessed >= MAX_IFRAMES_TO_PROCESS) break;
|
|
if (refCounter > MAX_SNAPSHOT_NODES) break;
|
|
|
|
const frameUrl = frame.url();
|
|
const frameName = frame.name();
|
|
|
|
// Skip tracking/analytics iframes
|
|
if (IFRAME_SKIP_PATTERNS.some(p => p.test(frameUrl) || p.test(frameName))) continue;
|
|
// Skip about:blank and empty frames
|
|
if (!frameUrl || frameUrl === 'about:blank' || frameUrl === 'about:srcdoc') continue;
|
|
|
|
try {
|
|
const frameYaml = await frame.locator('body').ariaSnapshot({ timeout: IFRAME_SNAPSHOT_TIMEOUT_MS });
|
|
if (!frameYaml || frameYaml.trim().length < 10) continue;
|
|
|
|
// Check if frame has any interactive elements
|
|
const hasInteractive = INTERACTIVE_ROLES.some(role => {
|
|
const regex = new RegExp(`^\\s*-\\s+${role}`, 'im');
|
|
return regex.test(frameYaml);
|
|
});
|
|
if (!hasInteractive) continue;
|
|
|
|
iframesProcessed++;
|
|
// Use a separate seenCounts for each iframe (nth is per-frame for locator resolution)
|
|
const frameSeenCounts = new Map();
|
|
|
|
const frameLines = frameYaml.split('\n');
|
|
for (const line of frameLines) {
|
|
if (refCounter > MAX_SNAPSHOT_NODES) break;
|
|
|
|
const match = line.match(/^\s*-\s+(\w+)(?:\s+"([^"]*)")?/);
|
|
if (match) {
|
|
const [, role, name] = match;
|
|
const normalizedRole = role.toLowerCase();
|
|
if (normalizedRole === 'combobox') continue;
|
|
if (name && SKIP_PATTERNS.some(p => p.test(name))) continue;
|
|
|
|
if (INTERACTIVE_ROLES.includes(normalizedRole)) {
|
|
const normalizedName = name || '';
|
|
const key = `${normalizedRole}:${normalizedName}`;
|
|
const nth = frameSeenCounts.get(key) || 0;
|
|
frameSeenCounts.set(key, nth + 1);
|
|
|
|
const refId = `e${refCounter++}`;
|
|
refs.set(refId, { role: normalizedRole, name: normalizedName, nth, frameName: frameName || null, frameUrl });
|
|
}
|
|
}
|
|
}
|
|
|
|
log('debug', 'buildRefs: processed iframe', { frameName, frameUrl: frameUrl.slice(0, 80), refs: refCounter - 1 });
|
|
} catch (err) {
|
|
// Frame might have navigated away or be inaccessible — skip silently
|
|
log('debug', 'buildRefs: iframe snapshot failed', { frameName, error: err.message?.slice(0, 80) });
|
|
}
|
|
}
|
|
|
|
if (iframesProcessed > 0) {
|
|
log('info', 'buildRefs: processed iframes', { count: iframesProcessed, totalRefs: refCounter - 1 });
|
|
}
|
|
}
|
|
|
|
return refs;
|
|
}
|
|
|
|
async function getAriaSnapshot(page) {
|
|
if (!page || page.isClosed()) {
|
|
return null;
|
|
}
|
|
await waitForPageReady(page, {
|
|
timeout: REFRESH_READY_TIMEOUT_MS,
|
|
waitForNetwork: false,
|
|
waitForHydration: false,
|
|
settleMs: 100,
|
|
});
|
|
let mainYaml;
|
|
try {
|
|
mainYaml = await page.locator('body').ariaSnapshot({ timeout: 5000 });
|
|
} catch (err) {
|
|
log('warn', 'getAriaSnapshot failed', { error: err.message });
|
|
return null;
|
|
}
|
|
|
|
if (!mainYaml) return null;
|
|
|
|
// --- IFRAME SUPPORT ---
|
|
// Append accessible iframe content to the snapshot YAML
|
|
const childFrames = page.frames().filter(f => f !== page.mainFrame());
|
|
const iframeYamls = [];
|
|
let iframesProcessed = 0;
|
|
|
|
for (const frame of childFrames) {
|
|
if (iframesProcessed >= MAX_IFRAMES_TO_PROCESS) break;
|
|
|
|
const frameUrl = frame.url();
|
|
const frameName = frame.name();
|
|
|
|
// Skip tracking/analytics iframes
|
|
if (IFRAME_SKIP_PATTERNS.some(p => p.test(frameUrl) || p.test(frameName))) continue;
|
|
if (!frameUrl || frameUrl === 'about:blank' || frameUrl === 'about:srcdoc') continue;
|
|
|
|
try {
|
|
const frameYaml = await frame.locator('body').ariaSnapshot({ timeout: IFRAME_SNAPSHOT_TIMEOUT_MS });
|
|
if (!frameYaml || frameYaml.trim().length < 10) continue;
|
|
|
|
// Only include frames with interactive elements
|
|
const hasInteractive = INTERACTIVE_ROLES.some(role => {
|
|
const regex = new RegExp(`^\\s*-\\s+${role}`, 'im');
|
|
return regex.test(frameYaml);
|
|
});
|
|
if (!hasInteractive) continue;
|
|
|
|
iframesProcessed++;
|
|
// Derive a human-readable label from frame name or URL
|
|
let label = frameName || '';
|
|
if (!label) {
|
|
try { label = new URL(frameUrl).hostname; } catch { label = 'iframe'; }
|
|
}
|
|
// Clean up Shopify-style frame names for readability
|
|
label = label.replace(/card-fields-/, '').replace(/-[a-z0-9]{10,}$/, '');
|
|
|
|
iframeYamls.push(`- iframe "${label}":\n${frameYaml.split('\n').map(l => ' ' + l).join('\n')}`);
|
|
} catch {
|
|
// Frame inaccessible — skip
|
|
}
|
|
}
|
|
|
|
if (iframeYamls.length > 0) {
|
|
return mainYaml + '\n' + iframeYamls.join('\n');
|
|
}
|
|
return mainYaml;
|
|
}
|
|
|
|
function refToLocator(page, ref, refs) {
|
|
const info = refs.get(ref);
|
|
if (!info) return null;
|
|
|
|
const { role, name, nth, frameName, frameUrl } = info;
|
|
|
|
// If ref belongs to an iframe, resolve via frame locator
|
|
if (frameName || frameUrl) {
|
|
let frame = null;
|
|
if (frameName) {
|
|
frame = page.frame({ name: frameName });
|
|
}
|
|
if (!frame && frameUrl) {
|
|
// Try matching by URL (partial match for long URLs)
|
|
frame = page.frames().find(f => f.url() === frameUrl || f.url().startsWith(frameUrl.slice(0, 80)));
|
|
}
|
|
if (frame) {
|
|
let locator = frame.getByRole(role, name ? { name } : undefined);
|
|
locator = locator.nth(nth);
|
|
return locator;
|
|
}
|
|
// Frame not found (navigated away?) — fall through to page-level resolution
|
|
log('warn', 'refToLocator: frame not found for iframe ref', { ref, frameName, frameUrl: frameUrl?.slice(0, 60) });
|
|
}
|
|
|
|
let locator = page.getByRole(role, name ? { name } : undefined);
|
|
|
|
// Always use .nth() to disambiguate duplicate role+name combinations
|
|
// This avoids "strict mode violation" when multiple elements match
|
|
locator = locator.nth(nth);
|
|
|
|
return locator;
|
|
}
|
|
|
|
async function refreshTabRefs(tabState, options = {}) {
|
|
const {
|
|
reason = 'refresh',
|
|
timeoutMs = null,
|
|
preserveExistingOnEmpty = true,
|
|
} = options;
|
|
|
|
const beforeUrl = tabState.page?.url?.() || '';
|
|
const existingRefs = tabState.refs instanceof Map ? tabState.refs : new Map();
|
|
const refreshPromise = buildRefs(tabState.page);
|
|
|
|
let refreshedRefs;
|
|
if (timeoutMs) {
|
|
const timeoutLabel = `${reason}_refs_timeout`;
|
|
refreshedRefs = await Promise.race([
|
|
refreshPromise,
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error(timeoutLabel)), timeoutMs)),
|
|
]);
|
|
} else {
|
|
refreshedRefs = await refreshPromise;
|
|
}
|
|
|
|
const afterUrl = tabState.page?.url?.() || beforeUrl;
|
|
if (preserveExistingOnEmpty && refreshedRefs.size === 0 && existingRefs.size > 0 && beforeUrl === afterUrl) {
|
|
log('warn', 'preserving previous refs after empty rebuild', {
|
|
reason,
|
|
url: afterUrl,
|
|
previousRefs: existingRefs.size,
|
|
});
|
|
return existingRefs;
|
|
}
|
|
|
|
return refreshedRefs;
|
|
}
|
|
|
|
|
|
/**
|
|
* @openapi
|
|
* /health:
|
|
* get:
|
|
* tags: [System]
|
|
* summary: Health check
|
|
* description: Detailed health with tab/session counts and failure tracking.
|
|
* responses:
|
|
* 200:
|
|
* description: Healthy.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* ok:
|
|
* type: boolean
|
|
* engine:
|
|
* type: string
|
|
* browserConnected:
|
|
* type: boolean
|
|
* browserRunning:
|
|
* type: boolean
|
|
* activeTabs:
|
|
* type: integer
|
|
* activeSessions:
|
|
* type: integer
|
|
* consecutiveFailures:
|
|
* type: integer
|
|
* 503:
|
|
* description: Unhealthy or recovering.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* ok:
|
|
* type: boolean
|
|
* recovering:
|
|
* type: boolean
|
|
*/
|
|
app.get('/health', (req, res) => {
|
|
if (healthState.isRecovering) {
|
|
return res.status(503).json({ ok: false, engine: 'camoufox', recovering: true });
|
|
}
|
|
const running = browser !== null && (browser.isConnected?.() ?? false);
|
|
const mem = process.memoryUsage();
|
|
const rssMb = Math.round(mem.rss / 1048576);
|
|
const heapUsedMb = Math.round(mem.heapUsed / 1048576);
|
|
const nativeMemMb = rssMb - heapUsedMb;
|
|
res.json({
|
|
ok: true,
|
|
engine: 'camoufox',
|
|
browserConnected: running,
|
|
browserRunning: running,
|
|
activeTabs: getTotalTabCount(),
|
|
activeSessions: sessions.size,
|
|
consecutiveFailures: healthState.consecutiveNavFailures,
|
|
memory: { rssMb, heapUsedMb, nativeMemMb },
|
|
...(FLY_MACHINE_ID ? { machineId: FLY_MACHINE_ID } : {}),
|
|
});
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /metrics:
|
|
* get:
|
|
* tags: [System]
|
|
* summary: Prometheus metrics
|
|
* description: Returns Prometheus text exposition format. Requires PROMETHEUS_ENABLED=1.
|
|
* responses:
|
|
* 200:
|
|
* description: Prometheus metrics.
|
|
* content:
|
|
* text/plain:
|
|
* schema:
|
|
* type: string
|
|
* 404:
|
|
* description: Metrics disabled.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
app.get('/metrics', async (_req, res) => {
|
|
const reg = getRegister();
|
|
if (!reg) {
|
|
res.status(404).json({ error: 'Prometheus metrics disabled. Set PROMETHEUS_ENABLED=1 to enable.' });
|
|
return;
|
|
}
|
|
res.set('Content-Type', reg.contentType);
|
|
res.send(await reg.metrics());
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /pressure/cleanup:
|
|
* post:
|
|
* tags: [System]
|
|
* summary: Proactive memory-pressure cleanup
|
|
* description: |
|
|
* Closes tabs observed idle across multiple checks while preserving tabs
|
|
* with active/queued operations. Never returns URLs, titles, cookies,
|
|
* page text, or user IDs. Defaults to dry-run mode.
|
|
* requestBody:
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* dryRun:
|
|
* type: boolean
|
|
* default: true
|
|
* description: When true, returns candidates without closing them.
|
|
* minIdleMs:
|
|
* type: number
|
|
* default: 600000
|
|
* description: Minimum idle time (ms) before a tab is eligible.
|
|
* maxTabsToClose:
|
|
* type: number
|
|
* default: 4
|
|
* description: Maximum tabs to close per invocation.
|
|
* minTabsPerSession:
|
|
* type: number
|
|
* default: 1
|
|
* description: Preserve at least this many tabs per session.
|
|
* closeEmptySessions:
|
|
* type: boolean
|
|
* default: true
|
|
* description: Close sessions left with zero tabs after cleanup.
|
|
* responses:
|
|
* 200:
|
|
* description: Cleanup result with before/after counts and hashed metadata.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* ok:
|
|
* type: boolean
|
|
* dryRun:
|
|
* type: boolean
|
|
* before:
|
|
* type: object
|
|
* properties:
|
|
* sessions:
|
|
* type: integer
|
|
* tabs:
|
|
* type: integer
|
|
* after:
|
|
* type: object
|
|
* properties:
|
|
* sessions:
|
|
* type: integer
|
|
* tabs:
|
|
* type: integer
|
|
* candidates:
|
|
* type: integer
|
|
* closed:
|
|
* type: array
|
|
* items:
|
|
* type: object
|
|
* preserved:
|
|
* type: object
|
|
*/
|
|
app.post('/pressure/cleanup', authMiddleware(), async (req, res) => {
|
|
try {
|
|
const result = await camofoxPressureCleanup(req.body || {});
|
|
log('info', 'pressure cleanup', {
|
|
dryRun: result.dryRun,
|
|
beforeTabs: result.before.tabs,
|
|
afterTabs: result.after.tabs,
|
|
candidates: result.candidates,
|
|
closed: result.closed.length,
|
|
preserved: result.preserved,
|
|
});
|
|
res.json(result);
|
|
} catch (err) {
|
|
log('error', 'pressure cleanup failed', { reqId: req.reqId, error: err.message });
|
|
handleRouteError(err, req, res);
|
|
}
|
|
});
|
|
|
|
// Create new tab
|
|
/**
|
|
* @openapi
|
|
* /tabs:
|
|
* post:
|
|
* tags: [Tabs]
|
|
* summary: Create a new tab
|
|
* description: Creates a tab in the given session. Optionally navigates to an initial URL.
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [userId, sessionKey]
|
|
* properties:
|
|
* userId:
|
|
* type: string
|
|
* description: Session owner.
|
|
* sessionKey:
|
|
* type: string
|
|
* description: Tab group identifier.
|
|
* listItemId:
|
|
* type: string
|
|
* description: Legacy alias for sessionKey.
|
|
* url:
|
|
* type: string
|
|
* description: Optional initial URL.
|
|
* trace:
|
|
* type: boolean
|
|
* description: Enable Playwright tracing for this session (screenshots, DOM snapshots, network). Must be set on first tab creation; cannot be added to an existing session.
|
|
* responses:
|
|
* 200:
|
|
* description: Tab created.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* tabId:
|
|
* type: string
|
|
* url:
|
|
* type: string
|
|
* 400:
|
|
* description: Missing required fields.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 429:
|
|
* description: Tab limit reached.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 409:
|
|
* description: Cannot enable tracing on an existing session.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
app.post('/tabs', async (req, res) => {
|
|
try {
|
|
const { userId, sessionKey, listItemId, url, trace } = req.body;
|
|
// Accept both sessionKey (preferred) and listItemId (legacy) for backward compatibility
|
|
const resolvedSessionKey = sessionKey || listItemId;
|
|
if (!userId || !resolvedSessionKey) {
|
|
return res.status(400).json({ error: 'userId and sessionKey required' });
|
|
}
|
|
|
|
// Session overflow redirect (Fly.io only) — if this machine is above its
|
|
// fair share of sessions and the user doesn't already have one here,
|
|
// bounce back through the Fly Proxy to land on a less-loaded machine.
|
|
if (FLY_MACHINE_ID) {
|
|
const PER_MACHINE_SESSION_CAP = Math.max(3, Math.ceil(MAX_SESSIONS / 3));
|
|
const key = normalizeUserId(userId);
|
|
const isReplayed = !!req.headers['fly-replay-src'];
|
|
if (sessions.size >= PER_MACHINE_SESSION_CAP && !sessions.has(key) && !isReplayed) {
|
|
log('info', 'session overflow redirect', {
|
|
userId: key, sessions: sessions.size, cap: PER_MACHINE_SESSION_CAP,
|
|
});
|
|
res.set('fly-replay', `app=${CONFIG.flyAppName || 'camofox-browser'}`);
|
|
return res.status(307).send();
|
|
}
|
|
}
|
|
|
|
const result = await withTimeout((async () => {
|
|
const existing = sessions.get(normalizeUserId(userId));
|
|
if (trace && existing && !existing.tracePath) {
|
|
throw Object.assign(
|
|
new Error('trace must be set on session creation. DELETE /sessions/:userId first to restart with tracing.'),
|
|
{ statusCode: 409 },
|
|
);
|
|
}
|
|
let session = await getSession(userId, { trace: !!trace });
|
|
|
|
let totalTabs = 0;
|
|
for (const group of session.tabGroups.values()) totalTabs += group.size;
|
|
|
|
// Recycle oldest tab when limits are reached instead of rejecting
|
|
if (totalTabs >= MAX_TABS_PER_SESSION || getTotalTabCount() >= MAX_TABS_GLOBAL) {
|
|
const recycled = await recycleOldestTab(session, req.reqId, userId);
|
|
if (!recycled) {
|
|
throw Object.assign(new Error('Maximum tabs per session reached'), { statusCode: 429 });
|
|
}
|
|
}
|
|
|
|
const group = getTabGroup(session, resolvedSessionKey);
|
|
|
|
const page = await session.context.newPage();
|
|
const tabId = fly.makeTabId();
|
|
let tabState = createTabState(page);
|
|
attachDownloadListener(tabState, tabId, log, pluginEvents, userId);
|
|
group.set(tabId, tabState);
|
|
attachPopupHandler(page, userId, resolvedSessionKey);
|
|
refreshActiveTabsGauge();
|
|
|
|
if (url) {
|
|
const urlErr = validateUrl(url);
|
|
if (urlErr) throw Object.assign(new Error(urlErr), { statusCode: 400 });
|
|
tabState.lastRequestedUrl = url;
|
|
try {
|
|
await withPageLoadDuration('open_url', () => page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }));
|
|
} catch (navErr) {
|
|
if ((isProxyError(navErr) || isTimeoutError(navErr)) && proxyPool?.canRotateSessions) {
|
|
log('warn', 'tab create navigate failed, retrying with fresh proxy', {
|
|
reqId: req.reqId, tabId, error: navErr.message,
|
|
});
|
|
browserRestartsTotal.labels('proxy_retry').inc();
|
|
const key = normalizeUserId(userId);
|
|
const oldSession = sessions.get(key);
|
|
if (oldSession) {
|
|
await closeSession(key, oldSession, { reason: 'proxy_retry_rotate', clearDownloads: true, clearLocks: true });
|
|
}
|
|
session = await getSession(userId, { trace: !!trace });
|
|
const retryGroup = getTabGroup(session, resolvedSessionKey);
|
|
const retryPage = await session.context.newPage();
|
|
tabState = createTabState(retryPage);
|
|
tabState.lastRequestedUrl = url;
|
|
attachDownloadListener(tabState, tabId, log, pluginEvents, userId);
|
|
retryGroup.set(tabId, tabState);
|
|
attachPopupHandler(retryPage, userId, resolvedSessionKey);
|
|
refreshActiveTabsGauge();
|
|
await withPageLoadDuration('open_url', () => retryPage.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }));
|
|
} else {
|
|
throw navErr;
|
|
}
|
|
}
|
|
tabState.visitedUrls.add(url);
|
|
}
|
|
|
|
pluginEvents.emit('tab:created', { userId, tabId, page, url: page.url() });
|
|
log('info', 'tab created', { reqId: req.reqId, tabId, userId, sessionKey: resolvedSessionKey, url: page.url() });
|
|
return { tabId, url: page.url() };
|
|
})(), requestTimeoutMs(), 'tab create');
|
|
|
|
res.json(result);
|
|
} catch (err) {
|
|
log('error', 'tab create failed', { reqId: req.reqId, error: err.message });
|
|
// SSL certificate errors on initial navigation — non-retriable
|
|
const isSslError = err.message && (
|
|
err.message.includes('SEC_ERROR') ||
|
|
err.message.includes('SSL_ERROR') ||
|
|
err.message.includes('MOZILLA_PKIX_ERROR')
|
|
);
|
|
if (isSslError) {
|
|
return res.status(502).json({
|
|
error: `SSL certificate error: ${err.message.split('\n')[0]}`,
|
|
code: 'ssl_error',
|
|
recoverable: false,
|
|
});
|
|
}
|
|
// Memory pressure / max sessions → bounce through LB to another machine
|
|
if (FLY_MACHINE_ID && err.statusCode === 503) {
|
|
res.set('fly-replay', `app=${CONFIG.flyAppName || 'camofox-browser'}`);
|
|
return res.status(503).json({ error: safeError(err), code: err.code || 'admission_rejected' });
|
|
}
|
|
handleRouteError(err, req, res);
|
|
}
|
|
});
|
|
|
|
// Navigate
|
|
/**
|
|
* @openapi
|
|
* /tabs/{tabId}/navigate:
|
|
* post:
|
|
* tags: [Navigation]
|
|
* summary: Navigate a tab to a URL or macro
|
|
* description: Navigate to a URL or expand a search macro. Auto-creates tab if not found.
|
|
* parameters:
|
|
* - name: tabId
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [userId]
|
|
* properties:
|
|
* userId:
|
|
* type: string
|
|
* url:
|
|
* type: string
|
|
* macro:
|
|
* type: string
|
|
* description: Search macro (e.g. @google_search).
|
|
* query:
|
|
* type: string
|
|
* description: Search query for macro.
|
|
* sessionKey:
|
|
* type: string
|
|
* listItemId:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Navigation result with snapshot.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* 400:
|
|
* description: Bad request.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 404:
|
|
* description: Tab not found.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
app.post('/tabs/:tabId/navigate', async (req, res) => {
|
|
const tabId = req.params.tabId;
|
|
|
|
try {
|
|
const { userId, url, macro, query, sessionKey, listItemId } = req.body;
|
|
if (!userId) return res.status(400).json({ error: 'userId required' });
|
|
|
|
let session = sessions.get(normalizeUserId(userId));
|
|
const found = session && findTab(session, tabId);
|
|
if (!found) return tabNotFoundResponse(res, tabId);
|
|
session.lastAccess = Date.now();
|
|
|
|
const result = await withUserLimit(userId, () => withTimeout((async () => {
|
|
await ensureBrowser();
|
|
const resolvedSessionKey = sessionKey || listItemId || found.listItemId || 'default';
|
|
let tabState = found.tabState;
|
|
tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
|
|
|
|
let targetUrl = url;
|
|
if (macro && macro !== '__NO__' && macro !== 'none' && macro !== 'null') {
|
|
targetUrl = expandMacro(macro, query) || url;
|
|
}
|
|
|
|
if (!targetUrl) throw new Error('url or macro required');
|
|
|
|
const urlErr = validateUrl(targetUrl);
|
|
if (urlErr) throw new Error(urlErr);
|
|
|
|
return await withTabLock(tabId, async () => {
|
|
const currentSessionKey = found?.listItemId || resolvedSessionKey;
|
|
const isGoogleSearch = isGoogleSearchUrl(targetUrl);
|
|
|
|
const navigateCurrentPage = async () => {
|
|
tabState.lastRequestedUrl = targetUrl;
|
|
const ac = tabState.navigateAbort = new AbortController();
|
|
const gotoP = withPageLoadDuration('navigate', () => tabState.page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }));
|
|
try {
|
|
await Promise.race([
|
|
gotoP,
|
|
new Promise((_, reject) => ac.signal.addEventListener('abort', () => reject(new Error('Navigation aborted: tab deleted')), { once: true })),
|
|
]);
|
|
tabState.visitedUrls.add(targetUrl);
|
|
tabState.lastSnapshot = null;
|
|
} catch (err) {
|
|
gotoP.catch(() => {}); // suppress unhandled rejection from still-pending goto
|
|
throw err;
|
|
} finally {
|
|
tabState.navigateAbort = null;
|
|
}
|
|
};
|
|
|
|
const prewarmGoogleHome = async () => {
|
|
if (!isGoogleSearch || tabState.visitedUrls.has('https://www.google.com/')) return;
|
|
await withPageLoadDuration('navigate', () => tabState.page.goto('https://www.google.com/', { waitUntil: 'domcontentloaded', timeout: 30000 }));
|
|
tabState.visitedUrls.add('https://www.google.com/');
|
|
await tabState.page.waitForTimeout(1200);
|
|
};
|
|
|
|
const recreateTabOnFreshContext = async () => {
|
|
const previousRetryCount = tabState.googleRetryCount || 0;
|
|
browserRestartsTotal.labels('google_search_block').inc();
|
|
// Rotate at context level -- destroy this user's session and create
|
|
// a fresh one with a new proxy session. Does NOT restart the browser.
|
|
const key = normalizeUserId(userId);
|
|
const oldSession = sessions.get(key);
|
|
if (oldSession) {
|
|
await closeSession(key, oldSession, { reason: 'google_blocked_context_rotate', clearDownloads: true, clearLocks: true });
|
|
}
|
|
session = await getSession(userId);
|
|
const group = getTabGroup(session, currentSessionKey);
|
|
const page = await session.context.newPage();
|
|
tabState = createTabState(page);
|
|
tabState.googleRetryCount = previousRetryCount + 1;
|
|
attachDownloadListener(tabState, tabId, log, pluginEvents, userId);
|
|
group.set(tabId, tabState);
|
|
attachPopupHandler(page, userId, currentSessionKey);
|
|
refreshActiveTabsGauge();
|
|
};
|
|
|
|
if (isGoogleSearch && proxyPool?.canRotateSessions) {
|
|
await prewarmGoogleHome();
|
|
}
|
|
|
|
// Navigate with transparent retry on proxy/timeout errors.
|
|
// If the proxy is blocked or the page times out, destroy the session,
|
|
// get a fresh proxy, and retry once before failing to the caller.
|
|
try {
|
|
await navigateCurrentPage();
|
|
} catch (navErr) {
|
|
if ((isProxyError(navErr) || isTimeoutError(navErr)) && proxyPool?.canRotateSessions) {
|
|
log('warn', 'navigate failed, retrying with fresh proxy session', {
|
|
reqId: req.reqId, tabId, error: navErr.message,
|
|
});
|
|
browserRestartsTotal.labels('proxy_retry').inc();
|
|
await recreateTabOnFreshContext();
|
|
if (isGoogleSearch) await prewarmGoogleHome();
|
|
await navigateCurrentPage();
|
|
} else {
|
|
throw navErr;
|
|
}
|
|
}
|
|
|
|
if (isGoogleSearch && proxyPool?.canRotateSessions && await isGoogleSearchBlocked(tabState.page)) {
|
|
log('warn', 'google search blocked, rotating browser proxy session', {
|
|
reqId: req.reqId,
|
|
tabId,
|
|
url: tabState.page.url(),
|
|
proxySession: browserLaunchProxy?.sessionId || null,
|
|
});
|
|
await recreateTabOnFreshContext();
|
|
await prewarmGoogleHome();
|
|
await navigateCurrentPage();
|
|
}
|
|
|
|
// For Google SERP: skip eager ref building during navigate.
|
|
// Results render asynchronously after DOMContentLoaded -- the snapshot
|
|
// call will wait for and extract them.
|
|
if (isGoogleSerp(tabState.page.url())) {
|
|
tabState.refs = new Map();
|
|
return { ok: true, tabId, url: tabState.page.url(), refsAvailable: false, googleSerp: true };
|
|
}
|
|
|
|
if (isGoogleSearch && await isGoogleSearchBlocked(tabState.page)) {
|
|
return { ok: false, tabId, url: tabState.page.url(), refsAvailable: false, googleBlocked: true };
|
|
}
|
|
|
|
tabState.refs = await buildRefs(tabState.page);
|
|
return { ok: true, tabId, url: tabState.page.url(), refsAvailable: tabState.refs.size > 0 };
|
|
}, requestTimeoutMs());
|
|
})(), requestTimeoutMs(), 'navigate'));
|
|
|
|
log('info', 'navigated', { reqId: req.reqId, tabId, url: result.url });
|
|
pluginEvents.emit('tab:navigated', { userId: req.body.userId, tabId, url: result.url, prevUrl: null });
|
|
res.json(result);
|
|
} catch (err) {
|
|
log('error', 'navigate failed', { reqId: req.reqId, tabId, error: err.message });
|
|
const is400 = err.message && (err.message.startsWith('Blocked URL scheme') || err.message === 'url or macro required');
|
|
if (is400) {
|
|
return res.status(400).json({ error: safeError(err) });
|
|
}
|
|
// SSL certificate errors — site has a bad/self-signed cert. Non-retriable.
|
|
const isSslError = err.message && (
|
|
err.message.includes('SEC_ERROR') ||
|
|
err.message.includes('SSL_ERROR') ||
|
|
err.message.includes('MOZILLA_PKIX_ERROR')
|
|
);
|
|
if (isSslError) {
|
|
return res.status(502).json({
|
|
error: `SSL certificate error: ${err.message.split('\n')[0]}`,
|
|
code: 'ssl_error',
|
|
recoverable: false,
|
|
});
|
|
}
|
|
handleRouteError(err, req, res);
|
|
}
|
|
});
|
|
|
|
// Snapshot
|
|
/**
|
|
* @openapi
|
|
* /tabs/{tabId}/snapshot:
|
|
* get:
|
|
* tags: [Content]
|
|
* summary: Accessibility snapshot
|
|
* description: Returns accessibility tree with element refs. Supports pagination via offset.
|
|
* parameters:
|
|
* - name: tabId
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* - name: userId
|
|
* in: query
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* - name: format
|
|
* in: query
|
|
* schema:
|
|
* type: string
|
|
* enum: [text, json]
|
|
* default: text
|
|
* - name: offset
|
|
* in: query
|
|
* schema:
|
|
* type: integer
|
|
* description: Character offset for paginated retrieval.
|
|
* - name: includeScreenshot
|
|
* in: query
|
|
* schema:
|
|
* type: string
|
|
* enum: ['true', 'false']
|
|
* responses:
|
|
* 200:
|
|
* description: Snapshot.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* url:
|
|
* type: string
|
|
* snapshot:
|
|
* type: string
|
|
* refsCount:
|
|
* type: integer
|
|
* truncated:
|
|
* type: boolean
|
|
* totalChars:
|
|
* type: integer
|
|
* hasMore:
|
|
* type: boolean
|
|
* nextOffset:
|
|
* type: integer
|
|
* 404:
|
|
* description: Tab not found.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
app.get('/tabs/:tabId/snapshot', async (req, res) => {
|
|
try {
|
|
const userId = req.query.userId;
|
|
if (!userId) return res.status(400).json({ error: 'userId required' });
|
|
const format = req.query.format || 'text';
|
|
const offset = parseInt(req.query.offset) || 0;
|
|
const session = sessions.get(normalizeUserId(userId));
|
|
const found = session && findTab(session, req.params.tabId);
|
|
if (!found) return tabNotFoundResponse(res, req.params.tabId || req.body?.tabId);
|
|
session.lastAccess = Date.now();
|
|
|
|
const { tabState } = found;
|
|
tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
|
|
|
|
// Cached chunk retrieval for offset>0 requests
|
|
if (offset > 0 && tabState.lastSnapshot) {
|
|
const win = windowSnapshot(tabState.lastSnapshot, offset);
|
|
const response = { url: tabState.page.url(), snapshot: win.text, refsCount: tabState.refs.size, truncated: win.truncated, totalChars: win.totalChars, hasMore: win.hasMore, nextOffset: win.nextOffset };
|
|
if (req.query.includeScreenshot === 'true') {
|
|
const pngBuffer = await tabState.page.screenshot({ type: 'png' });
|
|
response.screenshot = { data: pngBuffer.toString('base64'), mimeType: 'image/png' };
|
|
}
|
|
log('info', 'snapshot (cached offset)', { reqId: req.reqId, tabId: req.params.tabId, offset, totalChars: win.totalChars });
|
|
return res.json(response);
|
|
}
|
|
|
|
const result = await withUserLimit(userId, () => withTimeout((async () => {
|
|
if (proxyPool?.canRotateSessions && isGoogleSearchUrl(tabState.lastRequestedUrl || '')) {
|
|
const blocked = await isGoogleSearchBlocked(tabState.page);
|
|
const unavailable = !blocked && await isGoogleUnavailable(tabState.page);
|
|
if (blocked || unavailable) {
|
|
const rotated = await rotateGoogleTab(userId, found.listItemId, req.params.tabId, tabState, blocked ? 'google_search_block_snapshot' : 'google_search_unavailable_snapshot', req.reqId);
|
|
if (rotated) {
|
|
tabState.page = rotated.tabState.page;
|
|
tabState.refs = rotated.tabState.refs;
|
|
tabState.visitedUrls = rotated.tabState.visitedUrls;
|
|
tabState.downloads = rotated.tabState.downloads;
|
|
tabState.toolCalls = rotated.tabState.toolCalls;
|
|
tabState.consecutiveTimeouts = rotated.tabState.consecutiveTimeouts;
|
|
tabState.lastSnapshot = rotated.tabState.lastSnapshot;
|
|
tabState.lastRequestedUrl = rotated.tabState.lastRequestedUrl;
|
|
tabState.googleRetryCount = rotated.tabState.googleRetryCount;
|
|
}
|
|
}
|
|
}
|
|
|
|
const pageUrl = tabState.page.url();
|
|
|
|
// Google SERP fast path -- DOM extraction instead of ariaSnapshot
|
|
if (isGoogleSerp(pageUrl)) {
|
|
const { refs: googleRefs, snapshot: googleSnapshot } = await extractGoogleSerp(tabState.page);
|
|
tabState.refs = googleRefs;
|
|
tabState.lastSnapshot = googleSnapshot;
|
|
snapshotBytes.labels('google_serp').observe(Buffer.byteLength(googleSnapshot, 'utf8'));
|
|
const annotatedYaml = googleSnapshot;
|
|
const win = windowSnapshot(annotatedYaml, 0);
|
|
const response = {
|
|
url: pageUrl,
|
|
snapshot: win.text,
|
|
refsCount: tabState.refs.size,
|
|
truncated: win.truncated,
|
|
totalChars: win.totalChars,
|
|
hasMore: win.hasMore,
|
|
nextOffset: win.nextOffset,
|
|
};
|
|
if (req.query.includeScreenshot === 'true') {
|
|
const pngBuffer = await tabState.page.screenshot({ type: 'png' });
|
|
response.screenshot = { data: pngBuffer.toString('base64'), mimeType: 'image/png' };
|
|
}
|
|
return response;
|
|
}
|
|
|
|
tabState.refs = await refreshTabRefs(tabState, { reason: 'snapshot' });
|
|
const ariaYaml = await getAriaSnapshot(tabState.page);
|
|
|
|
let annotatedYaml = ariaYaml || '';
|
|
if (annotatedYaml && tabState.refs.size > 0) {
|
|
const refsByKey = new Map();
|
|
for (const [refId, info] of tabState.refs) {
|
|
const key = `${info.role}:${info.name}:${info.nth}`;
|
|
refsByKey.set(key, refId);
|
|
}
|
|
|
|
const annotationCounts = new Map();
|
|
const lines = annotatedYaml.split('\n');
|
|
|
|
annotatedYaml = lines.map(line => {
|
|
const match = line.match(/^(\s*-\s+)(\w+)(\s+"([^"]*)")?(.*)$/);
|
|
if (match) {
|
|
const [, prefix, role, nameMatch, name, suffix] = match;
|
|
const normalizedRole = role.toLowerCase();
|
|
if (normalizedRole === 'combobox') return line;
|
|
if (name && SKIP_PATTERNS.some(p => p.test(name))) return line;
|
|
if (INTERACTIVE_ROLES.includes(normalizedRole)) {
|
|
const normalizedName = name || '';
|
|
const countKey = `${normalizedRole}:${normalizedName}`;
|
|
const nth = annotationCounts.get(countKey) || 0;
|
|
annotationCounts.set(countKey, nth + 1);
|
|
const key = `${normalizedRole}:${normalizedName}:${nth}`;
|
|
const refId = refsByKey.get(key);
|
|
if (refId) {
|
|
return `${prefix}${role}${nameMatch || ''} [${refId}]${suffix}`;
|
|
}
|
|
}
|
|
}
|
|
return line;
|
|
}).join('\n');
|
|
}
|
|
|
|
tabState.lastSnapshot = annotatedYaml;
|
|
if (annotatedYaml) snapshotBytes.labels('full').observe(Buffer.byteLength(annotatedYaml, 'utf8'));
|
|
const win = windowSnapshot(annotatedYaml, 0);
|
|
|
|
const response = {
|
|
url: tabState.page.url(),
|
|
snapshot: win.text,
|
|
refsCount: tabState.refs.size,
|
|
truncated: win.truncated,
|
|
totalChars: win.totalChars,
|
|
hasMore: win.hasMore,
|
|
nextOffset: win.nextOffset,
|
|
};
|
|
|
|
if (req.query.includeScreenshot === 'true') {
|
|
const pngBuffer = await tabState.page.screenshot({ type: 'png' });
|
|
response.screenshot = { data: pngBuffer.toString('base64'), mimeType: 'image/png' };
|
|
}
|
|
|
|
return response;
|
|
})(), requestTimeoutMs(), 'snapshot'));
|
|
|
|
pluginEvents.emit('tab:snapshot', { userId: req.query.userId, tabId: req.params.tabId, snapshot: result.snapshot });
|
|
log('info', 'snapshot', { reqId: req.reqId, tabId: req.params.tabId, url: result.url, snapshotLen: result.snapshot?.length, refsCount: result.refsCount, hasScreenshot: !!result.screenshot, truncated: result.truncated });
|
|
res.json(result);
|
|
} catch (err) {
|
|
log('error', 'snapshot failed', { reqId: req.reqId, tabId: req.params.tabId, error: err.message });
|
|
handleRouteError(err, req, res);
|
|
}
|
|
});
|
|
|
|
// Wait for page ready
|
|
/**
|
|
* @openapi
|
|
* /tabs/{tabId}/wait:
|
|
* post:
|
|
* tags: [Interaction]
|
|
* summary: Wait for a selector or timeout
|
|
* parameters:
|
|
* - name: tabId
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [userId]
|
|
* properties:
|
|
* userId:
|
|
* type: string
|
|
* selector:
|
|
* type: string
|
|
* timeout:
|
|
* type: integer
|
|
* description: Max wait in ms.
|
|
* responses:
|
|
* 200:
|
|
* description: Wait completed.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* ok:
|
|
* type: boolean
|
|
* 404:
|
|
* description: Tab not found.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
app.post('/tabs/:tabId/wait', async (req, res) => {
|
|
try {
|
|
const { userId, timeout = 10000, waitForNetwork = true } = req.body;
|
|
const session = sessions.get(normalizeUserId(userId));
|
|
const found = session && findTab(session, req.params.tabId);
|
|
if (!found) return tabNotFoundResponse(res, req.params.tabId || req.body?.tabId);
|
|
session.lastAccess = Date.now();
|
|
|
|
const { tabState } = found;
|
|
const ready = await waitForPageReady(tabState.page, { timeout, waitForNetwork });
|
|
|
|
res.json({ ok: true, ready });
|
|
} catch (err) {
|
|
log('error', 'wait failed', { reqId: req.reqId, error: err.message });
|
|
handleRouteError(err, req, res);
|
|
}
|
|
});
|
|
|
|
// Click
|
|
/**
|
|
* @openapi
|
|
* /tabs/{tabId}/click:
|
|
* post:
|
|
* tags: [Interaction]
|
|
* summary: Click an element
|
|
* description: Click by element ref, CSS selector, or coordinates.
|
|
* parameters:
|
|
* - name: tabId
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [userId]
|
|
* properties:
|
|
* userId:
|
|
* type: string
|
|
* ref:
|
|
* type: string
|
|
* description: Element ref ID (e.g. "e3").
|
|
* selector:
|
|
* type: string
|
|
* description: CSS selector fallback.
|
|
* doubleClick:
|
|
* type: boolean
|
|
* coordinates:
|
|
* type: object
|
|
* properties:
|
|
* x:
|
|
* type: number
|
|
* y:
|
|
* type: number
|
|
* responses:
|
|
* 200:
|
|
* description: Click result with optional post-action snapshot.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* 400:
|
|
* description: Bad request.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 404:
|
|
* description: Tab not found.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
app.post('/tabs/:tabId/click', async (req, res) => {
|
|
const tabId = req.params.tabId;
|
|
|
|
try {
|
|
const { userId, ref, selector } = req.body;
|
|
if (!userId) return res.status(400).json({ error: 'userId required' });
|
|
const session = sessions.get(normalizeUserId(userId));
|
|
const found = session && findTab(session, tabId);
|
|
if (!found) return tabNotFoundResponse(res, req.params.tabId || req.body?.tabId);
|
|
session.lastAccess = Date.now();
|
|
|
|
const { tabState } = found;
|
|
tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
|
|
|
|
if (!ref && !selector) {
|
|
return res.status(400).json({ error: 'ref or selector required' });
|
|
}
|
|
|
|
const result = await withUserLimit(userId, () => withTabLock(tabId, async () => {
|
|
const clickStart = Date.now();
|
|
const remainingBudget = () => Math.max(0, HANDLER_TIMEOUT_MS - 2000 - (Date.now() - clickStart));
|
|
// Full mouse event sequence for stubborn JS click handlers (mirrors Swift WebView.swift)
|
|
// Dispatches: mouseover -> mouseenter -> mousedown -> mouseup -> click
|
|
const dispatchMouseSequence = async (locator) => {
|
|
const box = await locator.boundingBox();
|
|
if (!box) throw new Error('Element not visible (no bounding box)');
|
|
|
|
const x = box.x + box.width / 2;
|
|
const y = box.y + box.height / 2;
|
|
|
|
// Move mouse to element (triggers mouseover/mouseenter)
|
|
await tabState.page.mouse.move(x, y);
|
|
await tabState.page.waitForTimeout(50);
|
|
|
|
// Full click sequence
|
|
await tabState.page.mouse.down();
|
|
await tabState.page.waitForTimeout(50);
|
|
await tabState.page.mouse.up();
|
|
|
|
log('info', 'mouse sequence dispatched', { x: x.toFixed(0), y: y.toFixed(0) });
|
|
};
|
|
|
|
// On Google SERPs, skip the normal click attempt (always intercepted by overlays)
|
|
// and go directly to force click -- saves 5s timeout per click
|
|
const onGoogleSerp = isGoogleSerp(tabState.page.url());
|
|
|
|
const doClick = async (locatorOrSelector, isLocator) => {
|
|
const locator = isLocator ? locatorOrSelector : tabState.page.locator(locatorOrSelector);
|
|
|
|
if (onGoogleSerp) {
|
|
try {
|
|
await locator.click({ timeout: 3000, force: true });
|
|
} catch (forceErr) {
|
|
log('warn', 'google force click failed, trying mouse sequence');
|
|
await dispatchMouseSequence(locator);
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// First try normal click (respects visibility, enabled, not-obscured)
|
|
await locator.click({ timeout: 3000 });
|
|
} catch (err) {
|
|
// Fallback 1: If intercepted by overlay, retry with force
|
|
if (err.message.includes('intercepts pointer events')) {
|
|
log('warn', 'click intercepted, retrying with force');
|
|
try {
|
|
await locator.click({ timeout: 3000, force: true });
|
|
} catch (forceErr) {
|
|
// Fallback 2: Full mouse event sequence for stubborn JS handlers
|
|
log('warn', 'force click failed, trying mouse sequence');
|
|
await dispatchMouseSequence(locator);
|
|
}
|
|
} else if (err.message.includes('not visible') || err.message.toLowerCase().includes('timeout')) {
|
|
// Fallback 2: Element not responding to click, try mouse sequence
|
|
log('warn', 'click timeout, trying mouse sequence');
|
|
await dispatchMouseSequence(locator);
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
};
|
|
|
|
if (ref) {
|
|
let locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
if (!locator) {
|
|
// Use tight timeout (4s max) to leave budget for click + post-click buildRefs
|
|
log('info', 'auto-refreshing refs before click', { ref, hadRefs: tabState.refs.size });
|
|
try {
|
|
const preClickBudget = Math.min(4000, remainingBudget());
|
|
tabState.refs = await refreshTabRefs(tabState, { reason: 'pre_click', timeoutMs: preClickBudget });
|
|
} catch (e) {
|
|
if (e.message === 'pre_click_refs_timeout' || e.message === 'buildRefs_timeout') {
|
|
log('warn', 'pre-click buildRefs timed out, proceeding without refresh');
|
|
} else {
|
|
throw e;
|
|
}
|
|
}
|
|
locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
}
|
|
if (!locator) {
|
|
const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none';
|
|
throw new StaleRefsError(ref, maxRef, tabState.refs.size);
|
|
}
|
|
await doClick(locator, true);
|
|
} else {
|
|
await doClick(selector, false);
|
|
}
|
|
|
|
// If clicking on a Google SERP, wait for potential navigation to complete
|
|
if (onGoogleSerp) {
|
|
try {
|
|
await tabState.page.waitForLoadState('domcontentloaded', { timeout: 3000 });
|
|
} catch {}
|
|
await tabState.page.waitForTimeout(200);
|
|
// Skip buildRefs here -- SERP clicks typically navigate to a new page,
|
|
// and the caller always requests /snapshot next which rebuilds refs.
|
|
tabState.lastSnapshot = null;
|
|
tabState.refs = new Map();
|
|
const newUrl = tabState.page.url();
|
|
tabState.visitedUrls.add(newUrl);
|
|
return { ok: true, url: newUrl, refsAvailable: false };
|
|
} else {
|
|
await tabState.page.waitForTimeout(500);
|
|
}
|
|
tabState.lastSnapshot = null;
|
|
// buildRefs after click -- use remaining budget (min 2s) so we don't blow the handler timeout.
|
|
// If it times out, return without refs (caller's next /snapshot will rebuild them).
|
|
const postClickBudget = Math.max(2000, remainingBudget());
|
|
try {
|
|
tabState.refs = await refreshTabRefs(tabState, { reason: 'post_click', timeoutMs: postClickBudget });
|
|
} catch (e) {
|
|
if (e.message === 'post_click_refs_timeout' || e.message === 'buildRefs_timeout') {
|
|
log('warn', 'post-click buildRefs timed out, returning without refs', { budget: postClickBudget, elapsed: Date.now() - clickStart });
|
|
tabState.refs = new Map();
|
|
} else {
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
const newUrl = tabState.page.url();
|
|
tabState.visitedUrls.add(newUrl);
|
|
return { ok: true, url: newUrl, refsAvailable: tabState.refs.size > 0 };
|
|
}));
|
|
|
|
log('info', 'clicked', { reqId: req.reqId, tabId, url: result.url });
|
|
pluginEvents.emit('tab:click', { userId: req.body.userId, tabId, ref: req.body.ref, selector: req.body.selector });
|
|
res.json(result);
|
|
} catch (err) {
|
|
log('error', 'click failed', { reqId: req.reqId, tabId, error: err.message });
|
|
if (err.message?.includes('timed out')) {
|
|
try {
|
|
const session = sessions.get(normalizeUserId(req.body.userId));
|
|
const found = session && findTab(session, tabId);
|
|
if (found?.tabState?.page && !found.tabState.page.isClosed()) {
|
|
found.tabState.refs = await refreshTabRefs(found.tabState, { reason: 'click_timeout' });
|
|
found.tabState.lastSnapshot = null;
|
|
return res.status(500).json({
|
|
error: safeError(err),
|
|
hint: 'The page may have changed. Call snapshot to see the current state and retry.',
|
|
url: found.tabState.page.url(),
|
|
refsCount: found.tabState.refs.size,
|
|
});
|
|
}
|
|
} catch (refreshErr) {
|
|
log('warn', 'post-timeout refresh failed', { error: refreshErr.message });
|
|
}
|
|
}
|
|
handleRouteError(err, req, res);
|
|
}
|
|
});
|
|
|
|
// Type
|
|
/**
|
|
* @openapi
|
|
* /tabs/{tabId}/type:
|
|
* post:
|
|
* tags: [Interaction]
|
|
* summary: Type text into an element
|
|
* description: Types text into a focused element or a specific ref/selector.
|
|
* parameters:
|
|
* - name: tabId
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [userId, text]
|
|
* properties:
|
|
* userId:
|
|
* type: string
|
|
* ref:
|
|
* type: string
|
|
* selector:
|
|
* type: string
|
|
* text:
|
|
* type: string
|
|
* clear:
|
|
* type: boolean
|
|
* description: Clear field before typing.
|
|
* submit:
|
|
* type: boolean
|
|
* description: Press Enter after typing.
|
|
* responses:
|
|
* 200:
|
|
* description: Type result.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* 400:
|
|
* description: Bad request.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 404:
|
|
* description: Tab not found.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
app.post('/tabs/:tabId/type', async (req, res) => {
|
|
const tabId = req.params.tabId;
|
|
|
|
try {
|
|
const { userId, ref, selector, text, mode = 'fill', delay = 30, submit = false, pressEnter = false } = req.body;
|
|
const session = sessions.get(normalizeUserId(userId));
|
|
const found = session && findTab(session, tabId);
|
|
if (!found) return tabNotFoundResponse(res, req.params.tabId || req.body?.tabId);
|
|
session.lastAccess = Date.now();
|
|
|
|
const { tabState } = found;
|
|
tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
|
|
|
|
if (mode !== 'fill' && mode !== 'keyboard') {
|
|
return res.status(400).json({ error: "mode must be 'fill' or 'keyboard'" });
|
|
}
|
|
if (typeof text !== 'string') {
|
|
return res.status(400).json({ error: 'text is required' });
|
|
}
|
|
// keyboard mode: ref/selector are optional (types into current focus)
|
|
if (mode === 'fill' && !ref && !selector) {
|
|
return res.status(400).json({ error: 'ref or selector required for mode=fill' });
|
|
}
|
|
const shouldSubmit = submit || pressEnter;
|
|
|
|
await withTabLock(tabId, async () => {
|
|
// Resolve and focus the target if ref/selector provided
|
|
let locator = null;
|
|
if (ref) {
|
|
locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
if (!locator) {
|
|
log('info', 'auto-refreshing refs before type', { ref, hadRefs: tabState.refs.size, mode });
|
|
tabState.refs = await refreshTabRefs(tabState, { reason: 'type' });
|
|
locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
}
|
|
if (!locator) { const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none'; throw new StaleRefsError(ref, maxRef, tabState.refs.size); }
|
|
}
|
|
|
|
if (mode === 'fill') {
|
|
if (locator) {
|
|
await locator.fill(text, { timeout: 10000 });
|
|
} else {
|
|
await tabState.page.fill(selector, text, { timeout: 10000 });
|
|
}
|
|
} else {
|
|
// keyboard mode -- char-by-char real key events (required for Ember/contenteditable)
|
|
if (locator) {
|
|
await locator.focus({ timeout: 10000 });
|
|
} else if (selector) {
|
|
await tabState.page.focus(selector, { timeout: 10000 });
|
|
}
|
|
await tabState.page.keyboard.type(text, { delay });
|
|
}
|
|
if (shouldSubmit) await tabState.page.keyboard.press('Enter');
|
|
});
|
|
|
|
pluginEvents.emit('tab:type', { userId: req.body.userId, tabId, text: req.body.text, ref: req.body.ref, mode: req.body.mode || 'fill' });
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
log('error', 'type failed', { reqId: req.reqId, error: err.message });
|
|
if (err.message?.includes('timed out') || err.message?.includes('not an <input>')) {
|
|
try {
|
|
const session = sessions.get(normalizeUserId(req.body.userId));
|
|
const found = session && findTab(session, tabId);
|
|
if (found?.tabState?.page && !found.tabState.page.isClosed()) {
|
|
found.tabState.refs = await refreshTabRefs(found.tabState, { reason: 'type_timeout' });
|
|
found.tabState.lastSnapshot = null;
|
|
return res.status(500).json({
|
|
error: safeError(err),
|
|
hint: 'The page may have changed. Call snapshot to see the current state and retry.',
|
|
url: found.tabState.page.url(),
|
|
refsCount: found.tabState.refs.size,
|
|
});
|
|
}
|
|
} catch (refreshErr) {
|
|
log('warn', 'post-timeout refresh failed', { error: refreshErr.message });
|
|
}
|
|
}
|
|
handleRouteError(err, req, res);
|
|
}
|
|
});
|
|
|
|
// Press key
|
|
/**
|
|
* @openapi
|
|
* /tabs/{tabId}/press:
|
|
* post:
|
|
* tags: [Interaction]
|
|
* summary: Press a keyboard key
|
|
* parameters:
|
|
* - name: tabId
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [userId, key]
|
|
* properties:
|
|
* userId:
|
|
* type: string
|
|
* key:
|
|
* type: string
|
|
* description: Key name (e.g. "Enter", "Escape", "Tab").
|
|
* responses:
|
|
* 200:
|
|
* description: Key pressed.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* ok:
|
|
* type: boolean
|
|
* 404:
|
|
* description: Tab not found.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
app.post('/tabs/:tabId/press', async (req, res) => {
|
|
const tabId = req.params.tabId;
|
|
|
|
try {
|
|
const { userId, key } = req.body;
|
|
const session = sessions.get(normalizeUserId(userId));
|
|
const found = session && findTab(session, tabId);
|
|
if (!found) return tabNotFoundResponse(res, req.params.tabId || req.body?.tabId);
|
|
session.lastAccess = Date.now();
|
|
|
|
const { tabState } = found;
|
|
tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
|
|
|
|
await withTabLock(tabId, async () => {
|
|
await tabState.page.keyboard.press(key);
|
|
});
|
|
|
|
pluginEvents.emit('tab:press', { userId, tabId, key });
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
log('error', 'press failed', { reqId: req.reqId, error: err.message });
|
|
handleRouteError(err, req, res);
|
|
}
|
|
});
|
|
|
|
// Scroll
|
|
/**
|
|
* @openapi
|
|
* /tabs/{tabId}/scroll:
|
|
* post:
|
|
* tags: [Interaction]
|
|
* summary: Scroll the page
|
|
* parameters:
|
|
* - name: tabId
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [userId]
|
|
* properties:
|
|
* userId:
|
|
* type: string
|
|
* direction:
|
|
* type: string
|
|
* description: '"up" or "down" (default "down").'
|
|
* amount:
|
|
* type: integer
|
|
* description: Pixels to scroll.
|
|
* responses:
|
|
* 200:
|
|
* description: Scroll result.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* ok:
|
|
* type: boolean
|
|
* 404:
|
|
* description: Tab not found.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
app.post('/tabs/:tabId/scroll', async (req, res) => {
|
|
try {
|
|
const { userId, direction = 'down', amount = 500 } = req.body;
|
|
const session = sessions.get(normalizeUserId(userId));
|
|
const found = session && findTab(session, req.params.tabId);
|
|
if (!found) return tabNotFoundResponse(res, req.params.tabId || req.body?.tabId);
|
|
session.lastAccess = Date.now();
|
|
|
|
const { tabState } = found;
|
|
tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
|
|
|
|
await withTabLock(req.params.tabId, async () => {
|
|
const isVertical = direction === 'up' || direction === 'down';
|
|
const delta = (direction === 'up' || direction === 'left') ? -amount : amount;
|
|
await tabState.page.mouse.wheel(isVertical ? 0 : delta, isVertical ? delta : 0);
|
|
await tabState.page.waitForTimeout(300);
|
|
});
|
|
|
|
pluginEvents.emit('tab:scroll', { userId, tabId: req.params.tabId, direction, amount });
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
log('error', 'scroll failed', { reqId: req.reqId, error: err.message });
|
|
handleRouteError(err, req, res);
|
|
}
|
|
});
|
|
|
|
// Viewport
|
|
/**
|
|
* @openapi
|
|
* /tabs/{tabId}/viewport:
|
|
* post:
|
|
* tags: [Interaction]
|
|
* summary: Set the page viewport size
|
|
* description: >
|
|
* Physically resizes the page via Playwright's `page.setViewportSize`,
|
|
* triggering a real layout reflow. Use for responsive testing —
|
|
* `window.resizeTo()` is a no-op on non-popup windows.
|
|
* parameters:
|
|
* - name: tabId
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [userId, width, height]
|
|
* properties:
|
|
* userId:
|
|
* type: string
|
|
* width:
|
|
* type: integer
|
|
* minimum: 100
|
|
* maximum: 4000
|
|
* height:
|
|
* type: integer
|
|
* minimum: 100
|
|
* maximum: 4000
|
|
* responses:
|
|
* 200:
|
|
* description: Viewport set.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* ok:
|
|
* type: boolean
|
|
* width:
|
|
* type: integer
|
|
* height:
|
|
* type: integer
|
|
* 400:
|
|
* description: Width or height missing or out of range.
|
|
* 404:
|
|
* description: Tab not found.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
app.post('/tabs/:tabId/viewport', async (req, res) => {
|
|
try {
|
|
const { userId, width, height } = req.body;
|
|
if (!Number.isFinite(width) || !Number.isFinite(height) || width < 100 || height < 100 || width > 4000 || height > 4000) {
|
|
return res.status(400).json({ error: 'width and height required (100..4000 px)' });
|
|
}
|
|
const session = sessions.get(normalizeUserId(userId));
|
|
const found = session && findTab(session, req.params.tabId);
|
|
if (!found) return res.status(404).json({ error: 'Tab not found' });
|
|
session.lastAccess = Date.now();
|
|
|
|
const { tabState } = found;
|
|
tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
|
|
|
|
await tabState.page.setViewportSize({ width: Math.round(width), height: Math.round(height) });
|
|
await tabState.page.waitForTimeout(150);
|
|
|
|
pluginEvents.emit('tab:viewport', { userId, tabId: req.params.tabId, width, height });
|
|
res.json({ ok: true, width: Math.round(width), height: Math.round(height) });
|
|
} catch (err) {
|
|
log('error', 'viewport failed', { reqId: req.reqId, error: err.message });
|
|
handleRouteError(err, req, res);
|
|
}
|
|
});
|
|
|
|
// Back
|
|
/**
|
|
* @openapi
|
|
* /tabs/{tabId}/back:
|
|
* post:
|
|
* tags: [Navigation]
|
|
* summary: Go back
|
|
* parameters:
|
|
* - name: tabId
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [userId]
|
|
* properties:
|
|
* userId:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Navigated back.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* ok:
|
|
* type: boolean
|
|
* url:
|
|
* type: string
|
|
* 404:
|
|
* description: Tab not found.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
app.post('/tabs/:tabId/back', async (req, res) => {
|
|
const tabId = req.params.tabId;
|
|
|
|
try {
|
|
const { userId } = req.body;
|
|
const session = sessions.get(normalizeUserId(userId));
|
|
const found = session && findTab(session, tabId);
|
|
if (!found) return tabNotFoundResponse(res, req.params.tabId || req.body?.tabId);
|
|
session.lastAccess = Date.now();
|
|
|
|
const { tabState } = found;
|
|
tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
|
|
|
|
const result = await withTabLock(tabId, async () => {
|
|
try {
|
|
await tabState.page.goBack({ timeout: 20000 });
|
|
} catch (navErr) {
|
|
// NS_BINDING_CANCELLED_OLD_LOAD: Firefox cancels the old load when going back.
|
|
// The navigation itself succeeded -- just the prior page's load was interrupted.
|
|
if (navErr.message && navErr.message.includes('NS_BINDING_CANCELLED')) {
|
|
log('info', 'goBack cancelled old load (expected)', { reqId: req.reqId, tabId });
|
|
} else {
|
|
throw navErr;
|
|
}
|
|
}
|
|
tabState.refs = await buildRefs(tabState.page);
|
|
return { ok: true, url: tabState.page.url() };
|
|
});
|
|
|
|
res.json(result);
|
|
} catch (err) {
|
|
log('error', 'back failed', { reqId: req.reqId, error: err.message });
|
|
handleRouteError(err, req, res);
|
|
}
|
|
});
|
|
|
|
// Forward
|
|
/**
|
|
* @openapi
|
|
* /tabs/{tabId}/forward:
|
|
* post:
|
|
* tags: [Navigation]
|
|
* summary: Go forward
|
|
* parameters:
|
|
* - name: tabId
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [userId]
|
|
* properties:
|
|
* userId:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Navigated forward.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* ok:
|
|
* type: boolean
|
|
* url:
|
|
* type: string
|
|
* 404:
|
|
* description: Tab not found.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
app.post('/tabs/:tabId/forward', async (req, res) => {
|
|
const tabId = req.params.tabId;
|
|
|
|
try {
|
|
const { userId } = req.body;
|
|
const session = sessions.get(normalizeUserId(userId));
|
|
const found = session && findTab(session, tabId);
|
|
if (!found) return tabNotFoundResponse(res, req.params.tabId || req.body?.tabId);
|
|
session.lastAccess = Date.now();
|
|
|
|
const { tabState } = found;
|
|
tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
|
|
|
|
const result = await withTabLock(tabId, async () => {
|
|
await tabState.page.goForward({ timeout: 10000 });
|
|
tabState.refs = await buildRefs(tabState.page);
|
|
return { ok: true, url: tabState.page.url() };
|
|
});
|
|
|
|
res.json(result);
|
|
} catch (err) {
|
|
log('error', 'forward failed', { reqId: req.reqId, error: err.message });
|
|
handleRouteError(err, req, res);
|
|
}
|
|
});
|
|
|
|
// Refresh
|
|
/**
|
|
* @openapi
|
|
* /tabs/{tabId}/refresh:
|
|
* post:
|
|
* tags: [Navigation]
|
|
* summary: Refresh page
|
|
* parameters:
|
|
* - name: tabId
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [userId]
|
|
* properties:
|
|
* userId:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Page refreshed.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* ok:
|
|
* type: boolean
|
|
* url:
|
|
* type: string
|
|
* 404:
|
|
* description: Tab not found.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
app.post('/tabs/:tabId/refresh', async (req, res) => {
|
|
const tabId = req.params.tabId;
|
|
|
|
try {
|
|
const { userId } = req.body;
|
|
const session = sessions.get(normalizeUserId(userId));
|
|
const found = session && findTab(session, tabId);
|
|
if (!found) return tabNotFoundResponse(res, req.params.tabId || req.body?.tabId);
|
|
session.lastAccess = Date.now();
|
|
|
|
const { tabState } = found;
|
|
tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
|
|
|
|
const result = await withTabLock(tabId, async () => {
|
|
await tabState.page.reload({ timeout: 30000 });
|
|
tabState.refs = await buildRefs(tabState.page);
|
|
return { ok: true, url: tabState.page.url() };
|
|
});
|
|
|
|
res.json(result);
|
|
} catch (err) {
|
|
log('error', 'refresh failed', { reqId: req.reqId, error: err.message });
|
|
handleRouteError(err, req, res);
|
|
}
|
|
});
|
|
|
|
// Get links
|
|
/**
|
|
* @openapi
|
|
* /tabs/{tabId}/links:
|
|
* get:
|
|
* tags: [Content]
|
|
* summary: Extract page links
|
|
* parameters:
|
|
* - name: tabId
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* - name: userId
|
|
* in: query
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Links extracted.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* links:
|
|
* type: array
|
|
* items:
|
|
* type: object
|
|
* properties:
|
|
* text:
|
|
* type: string
|
|
* href:
|
|
* type: string
|
|
* ref:
|
|
* type: string
|
|
* 404:
|
|
* description: Tab not found.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
app.get('/tabs/:tabId/links', async (req, res) => {
|
|
try {
|
|
const userId = req.query.userId;
|
|
const limit = parseInt(req.query.limit) || 50;
|
|
const offset = parseInt(req.query.offset) || 0;
|
|
const session = sessions.get(normalizeUserId(userId));
|
|
const found = session && findTab(session, req.params.tabId);
|
|
if (!found) {
|
|
log('warn', 'links: tab not found', { reqId: req.reqId, tabId: req.params.tabId, userId, hasSession: !!session });
|
|
return tabNotFoundResponse(res, req.params.tabId);
|
|
}
|
|
session.lastAccess = Date.now();
|
|
|
|
const { tabState } = found;
|
|
tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
|
|
|
|
const result = await withTabLock(req.params.tabId, async () => {
|
|
const allLinks = await tabState.page.evaluate(() => {
|
|
const links = [];
|
|
document.querySelectorAll('a[href]').forEach(a => {
|
|
const href = a.href;
|
|
const text = a.textContent?.trim().slice(0, 100) || '';
|
|
if (href && href.startsWith('http')) {
|
|
links.push({ url: href, text });
|
|
}
|
|
});
|
|
return links;
|
|
});
|
|
|
|
const total = allLinks.length;
|
|
const paginated = allLinks.slice(offset, offset + limit);
|
|
|
|
return {
|
|
links: paginated,
|
|
pagination: { total, offset, limit, hasMore: offset + limit < total }
|
|
};
|
|
});
|
|
|
|
res.json(result);
|
|
} catch (err) {
|
|
log('error', 'links failed', { reqId: req.reqId, error: err.message });
|
|
handleRouteError(err, req, res);
|
|
}
|
|
});
|
|
|
|
// Get captured downloads
|
|
/**
|
|
* @openapi
|
|
* /tabs/{tabId}/downloads:
|
|
* get:
|
|
* tags: [Content]
|
|
* summary: List tab downloads
|
|
* parameters:
|
|
* - name: tabId
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* - name: userId
|
|
* in: query
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Downloads list.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* downloads:
|
|
* type: array
|
|
* items:
|
|
* type: object
|
|
* properties:
|
|
* filename:
|
|
* type: string
|
|
* url:
|
|
* type: string
|
|
* state:
|
|
* type: string
|
|
* 404:
|
|
* description: Tab not found.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
app.get('/tabs/:tabId/downloads', async (req, res) => {
|
|
try {
|
|
const userId = req.query.userId;
|
|
const includeData = req.query.includeData === 'true';
|
|
const consume = req.query.consume === 'true';
|
|
const maxBytesRaw = Number(req.query.maxBytes);
|
|
const maxBytes = Number.isFinite(maxBytesRaw) && maxBytesRaw > 0 ? maxBytesRaw : MAX_DOWNLOAD_INLINE_BYTES;
|
|
const session = sessions.get(normalizeUserId(userId));
|
|
const found = session && findTab(session, req.params.tabId);
|
|
if (!found) return tabNotFoundResponse(res, req.params.tabId || req.body?.tabId);
|
|
session.lastAccess = Date.now();
|
|
|
|
const { tabState } = found;
|
|
tabState.toolCalls++;
|
|
|
|
const downloads = await getDownloadsList(tabState, { includeData, maxBytes });
|
|
|
|
if (consume) {
|
|
await clearTabDownloads(tabState);
|
|
}
|
|
|
|
res.json({ tabId: req.params.tabId, downloads });
|
|
} catch (err) {
|
|
failuresTotal.labels(classifyError(err), 'downloads').inc();
|
|
log('error', 'downloads failed', { reqId: req.reqId, error: err.message });
|
|
res.status(500).json({ error: safeError(err) });
|
|
}
|
|
});
|
|
|
|
// Get image elements from current page
|
|
/**
|
|
* @openapi
|
|
* /tabs/{tabId}/images:
|
|
* get:
|
|
* tags: [Content]
|
|
* summary: Extract page images
|
|
* parameters:
|
|
* - name: tabId
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* - name: userId
|
|
* in: query
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Images extracted.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* images:
|
|
* type: array
|
|
* items:
|
|
* type: object
|
|
* properties:
|
|
* src:
|
|
* type: string
|
|
* alt:
|
|
* type: string
|
|
* width:
|
|
* type: integer
|
|
* height:
|
|
* type: integer
|
|
* 404:
|
|
* description: Tab not found.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
app.get('/tabs/:tabId/images', async (req, res) => {
|
|
try {
|
|
const userId = req.query.userId;
|
|
const includeData = req.query.includeData === 'true';
|
|
const maxBytesRaw = Number(req.query.maxBytes);
|
|
const limitRaw = Number(req.query.limit);
|
|
const maxBytes = Number.isFinite(maxBytesRaw) && maxBytesRaw > 0 ? maxBytesRaw : MAX_DOWNLOAD_INLINE_BYTES;
|
|
const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(Math.floor(limitRaw), 20) : 8;
|
|
const session = sessions.get(normalizeUserId(userId));
|
|
const found = session && findTab(session, req.params.tabId);
|
|
if (!found) return tabNotFoundResponse(res, req.params.tabId || req.body?.tabId);
|
|
session.lastAccess = Date.now();
|
|
|
|
const { tabState } = found;
|
|
tabState.toolCalls++;
|
|
|
|
const images = await extractPageImages(tabState.page, { includeData, maxBytes, limit });
|
|
|
|
res.json({ tabId: req.params.tabId, images });
|
|
} catch (err) {
|
|
failuresTotal.labels(classifyError(err), 'images').inc();
|
|
log('error', 'images failed', { reqId: req.reqId, error: err.message });
|
|
res.status(500).json({ error: safeError(err) });
|
|
}
|
|
});
|
|
|
|
// Screenshot
|
|
/**
|
|
* @openapi
|
|
* /tabs/{tabId}/screenshot:
|
|
* get:
|
|
* tags: [Content]
|
|
* summary: Take a screenshot
|
|
* description: Returns a base64-encoded PNG screenshot.
|
|
* parameters:
|
|
* - name: tabId
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* - name: userId
|
|
* in: query
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Screenshot.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* screenshot:
|
|
* type: object
|
|
* properties:
|
|
* data:
|
|
* type: string
|
|
* mimeType:
|
|
* type: string
|
|
* 404:
|
|
* description: Tab not found.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
app.get('/tabs/:tabId/screenshot', async (req, res) => {
|
|
try {
|
|
const userId = req.query.userId;
|
|
const fullPage = req.query.fullPage === 'true';
|
|
const session = sessions.get(normalizeUserId(userId));
|
|
const found = session && findTab(session, req.params.tabId);
|
|
if (!found) return tabNotFoundResponse(res, req.params.tabId || req.body?.tabId);
|
|
session.lastAccess = Date.now();
|
|
|
|
const { tabState } = found;
|
|
const buffer = await tabState.page.screenshot({ type: 'png', fullPage });
|
|
pluginEvents.emit('tab:screenshot', { userId, tabId: req.params.tabId, buffer });
|
|
res.set('Content-Type', 'image/png');
|
|
res.send(buffer);
|
|
} catch (err) {
|
|
log('error', 'screenshot failed', { reqId: req.reqId, error: err.message });
|
|
handleRouteError(err, req, res);
|
|
}
|
|
});
|
|
|
|
// Stats
|
|
/**
|
|
* @openapi
|
|
* /tabs/{tabId}/stats:
|
|
* get:
|
|
* tags: [Tabs]
|
|
* summary: Tab statistics
|
|
* description: Returns tab metadata including URL, tool call count, visited URLs, download/failure counts.
|
|
* parameters:
|
|
* - name: tabId
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* - name: userId
|
|
* in: query
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Tab stats.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* tabId:
|
|
* type: string
|
|
* url:
|
|
* type: string
|
|
* toolCalls:
|
|
* type: integer
|
|
* visitedUrls:
|
|
* type: array
|
|
* items:
|
|
* type: string
|
|
* downloadCount:
|
|
* type: integer
|
|
* consecutiveFailures:
|
|
* type: integer
|
|
* 404:
|
|
* description: Tab not found.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
app.get('/tabs/:tabId/stats', async (req, res) => {
|
|
try {
|
|
const userId = req.query.userId;
|
|
const session = sessions.get(normalizeUserId(userId));
|
|
const found = session && findTab(session, req.params.tabId);
|
|
if (!found) return tabNotFoundResponse(res, req.params.tabId || req.body?.tabId);
|
|
session.lastAccess = Date.now();
|
|
|
|
const { tabState, listItemId } = found;
|
|
res.json({
|
|
tabId: req.params.tabId,
|
|
sessionKey: listItemId,
|
|
listItemId, // Legacy compatibility
|
|
url: tabState.page.url(),
|
|
visitedUrls: Array.from(tabState.visitedUrls),
|
|
downloadsCount: Array.isArray(tabState.downloads) ? tabState.downloads.length : 0,
|
|
toolCalls: tabState.toolCalls,
|
|
refsCount: tabState.refs.size
|
|
});
|
|
} catch (err) {
|
|
log('error', 'stats failed', { reqId: req.reqId, error: err.message });
|
|
handleRouteError(err, req, res);
|
|
}
|
|
});
|
|
|
|
// Evaluate JavaScript in page context
|
|
/**
|
|
* @openapi
|
|
* /tabs/{tabId}/evaluate:
|
|
* post:
|
|
* tags: [Interaction]
|
|
* summary: Evaluate JavaScript in tab
|
|
* description: Runs arbitrary JS in the page context and returns the result.
|
|
* parameters:
|
|
* - name: tabId
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [userId, expression]
|
|
* properties:
|
|
* userId:
|
|
* type: string
|
|
* expression:
|
|
* type: string
|
|
* description: JavaScript expression to evaluate.
|
|
* responses:
|
|
* 200:
|
|
* description: Evaluation result.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* ok:
|
|
* type: boolean
|
|
* result: {}
|
|
* 400:
|
|
* description: Bad request.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 404:
|
|
* description: Tab not found.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
app.post('/tabs/:tabId/evaluate', authMiddleware(), express.json({ limit: '1mb' }), async (req, res) => {
|
|
try {
|
|
const { userId, expression } = req.body;
|
|
if (!userId) return res.status(400).json({ error: 'userId is required' });
|
|
if (!expression) return res.status(400).json({ error: 'expression is required' });
|
|
|
|
const session = sessions.get(normalizeUserId(userId));
|
|
const found = session && findTab(session, req.params.tabId);
|
|
if (!found) return tabNotFoundResponse(res, req.params.tabId || req.body?.tabId);
|
|
|
|
session.lastAccess = Date.now();
|
|
const { tabState } = found;
|
|
tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
|
|
|
|
pluginEvents.emit('tab:evaluate', { userId, tabId: req.params.tabId, expression });
|
|
const result = await tabState.page.evaluate(expression);
|
|
pluginEvents.emit('tab:evaluated', { userId, tabId: req.params.tabId, result });
|
|
log('info', 'evaluate', { reqId: req.reqId, tabId: req.params.tabId, userId, resultType: typeof result });
|
|
res.json({ ok: true, result });
|
|
} catch (err) {
|
|
failuresTotal.labels(classifyError(err), 'evaluate').inc();
|
|
log('error', 'evaluate failed', { reqId: req.reqId, error: err.message });
|
|
res.status(500).json({ error: safeError(err) });
|
|
}
|
|
});
|
|
|
|
// Structured extraction using JSON Schema with x-ref hints
|
|
/**
|
|
* @openapi
|
|
* /tabs/{tabId}/extract:
|
|
* post:
|
|
* tags: [Content]
|
|
* summary: Structured data extraction via JSON Schema
|
|
* description: |
|
|
* Extracts structured data from the current page using a JSON Schema whose properties
|
|
* carry `x-ref` hints pointing at snapshot element refs (e.g. `e1`, `e2`).
|
|
* Call `GET /tabs/{tabId}/snapshot` first to populate the ref table.
|
|
* parameters:
|
|
* - name: tabId
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [userId, schema]
|
|
* properties:
|
|
* userId:
|
|
* type: string
|
|
* schema:
|
|
* type: object
|
|
* description: |
|
|
* JSON Schema with `type: "object"` and a `properties` map.
|
|
* Each property may include `x-ref` (a snapshot element ref) and an optional
|
|
* `type` (`string`, `number`, `integer`, `boolean`).
|
|
* required: [type, properties]
|
|
* properties:
|
|
* type:
|
|
* type: string
|
|
* enum: [object]
|
|
* properties:
|
|
* type: object
|
|
* additionalProperties:
|
|
* type: object
|
|
* properties:
|
|
* type:
|
|
* type: string
|
|
* enum: [string, number, integer, boolean, object, "null"]
|
|
* x-ref:
|
|
* type: string
|
|
* description: Snapshot element ref (e.g. `e1`).
|
|
* required:
|
|
* type: array
|
|
* items:
|
|
* type: string
|
|
* description: Property names that must resolve to a non-null value.
|
|
* responses:
|
|
* 200:
|
|
* description: Extraction succeeded.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* ok:
|
|
* type: boolean
|
|
* data:
|
|
* type: object
|
|
* description: Extracted key-value pairs matching the input schema.
|
|
* 400:
|
|
* description: Missing userId, missing schema, or invalid schema.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 404:
|
|
* description: Tab not found.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 409:
|
|
* description: No refs available -- call snapshot first.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* error:
|
|
* type: string
|
|
* snapshot:
|
|
* type: string
|
|
* nullable: true
|
|
* 422:
|
|
* description: Extraction failed (e.g. required ref not found).
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* ok:
|
|
* type: boolean
|
|
* error:
|
|
* type: string
|
|
* snapshot:
|
|
* type: string
|
|
* nullable: true
|
|
* 500:
|
|
* description: Internal server error.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
app.post('/tabs/:tabId/extract', express.json({ limit: '256kb' }), async (req, res) => {
|
|
try {
|
|
const { userId, schema } = req.body;
|
|
if (!userId) return res.status(400).json({ error: 'userId is required' });
|
|
if (!schema) return res.status(400).json({ error: 'schema is required' });
|
|
|
|
const check = validateExtractSchema(schema);
|
|
if (!check.ok) return res.status(400).json({ error: check.error });
|
|
|
|
const session = sessions.get(normalizeUserId(userId));
|
|
const found = session && findTab(session, req.params.tabId);
|
|
if (!found) return tabNotFoundResponse(res, req.params.tabId || req.body?.tabId);
|
|
|
|
session.lastAccess = Date.now();
|
|
const { tabState } = found;
|
|
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
|
|
if (!tabState.refs || tabState.refs.size === 0) {
|
|
return res.status(409).json({
|
|
error: 'no refs available -- call GET /tabs/:tabId/snapshot first to build the ref table',
|
|
snapshot: tabState.lastSnapshot || null,
|
|
});
|
|
}
|
|
|
|
try {
|
|
const data = extractDeterministic({ schema, refs: tabState.refs });
|
|
log('info', 'extract', { reqId: req.reqId, tabId: req.params.tabId, userId, keys: Object.keys(data) });
|
|
res.json({ ok: true, data });
|
|
} catch (extractErr) {
|
|
log('warn', 'extract failed', { reqId: req.reqId, error: extractErr.message });
|
|
res.status(422).json({ ok: false, error: extractErr.message, snapshot: tabState.lastSnapshot || null });
|
|
}
|
|
} catch (err) {
|
|
failuresTotal.labels(classifyError(err), 'extract').inc();
|
|
log('error', 'extract error', { reqId: req.reqId, error: err.message });
|
|
res.status(500).json({ error: safeError(err) });
|
|
}
|
|
});
|
|
|
|
// Close tab
|
|
/**
|
|
* @openapi
|
|
* /tabs/{tabId}:
|
|
* delete:
|
|
* tags: [Tabs]
|
|
* summary: Close a tab
|
|
* parameters:
|
|
* - name: tabId
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* - name: userId
|
|
* in: query
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Tab closed.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* ok:
|
|
* type: boolean
|
|
* 404:
|
|
* description: Tab not found.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
app.delete('/tabs/:tabId', async (req, res) => {
|
|
try {
|
|
const userId = req.query.userId || req.body?.userId;
|
|
if (!userId) return res.status(400).json({ error: 'userId required (query or body)' });
|
|
const session = sessions.get(normalizeUserId(userId));
|
|
const found = session && findTab(session, req.params.tabId);
|
|
if (found) {
|
|
if (found.tabState.navigateAbort) found.tabState.navigateAbort.abort();
|
|
await clearTabDownloads(found.tabState);
|
|
await safePageClose(found.tabState.page);
|
|
found.group.delete(req.params.tabId);
|
|
{ const _l = tabLocks.get(req.params.tabId); if (_l) _l.drain(); tabLocks.delete(req.params.tabId); refreshTabLockQueueDepth(); }
|
|
if (found.group.size === 0) {
|
|
session.tabGroups.delete(found.listItemId);
|
|
}
|
|
refreshActiveTabsGauge();
|
|
log('info', 'tab closed', { reqId: req.reqId, tabId: req.params.tabId, userId });
|
|
}
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
log('error', 'tab close failed', { reqId: req.reqId, error: err.message });
|
|
handleRouteError(err, req, res);
|
|
}
|
|
});
|
|
|
|
// Close tab group
|
|
/**
|
|
* @openapi
|
|
* /tabs/group/{listItemId}:
|
|
* delete:
|
|
* tags: [Tabs]
|
|
* summary: Close all tabs in a group
|
|
* parameters:
|
|
* - name: listItemId
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* - name: userId
|
|
* in: query
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Group closed.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* ok:
|
|
* type: boolean
|
|
* closed:
|
|
* type: integer
|
|
* 404:
|
|
* description: Session not found.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
app.delete('/tabs/group/:listItemId', async (req, res) => {
|
|
try {
|
|
const userId = req.query.userId || req.body?.userId;
|
|
if (!userId) return res.status(400).json({ error: 'userId required (query or body)' });
|
|
const session = sessions.get(normalizeUserId(userId));
|
|
const group = session?.tabGroups.get(req.params.listItemId);
|
|
if (group) {
|
|
for (const [tabId, tabState] of group) {
|
|
await clearTabDownloads(tabState);
|
|
await safePageClose(tabState.page);
|
|
const lock = tabLocks.get(tabId);
|
|
if (lock) {
|
|
lock.drain();
|
|
tabLocks.delete(tabId);
|
|
}
|
|
}
|
|
session.tabGroups.delete(req.params.listItemId);
|
|
refreshTabLockQueueDepth();
|
|
refreshActiveTabsGauge();
|
|
log('info', 'tab group closed', { reqId: req.reqId, listItemId: req.params.listItemId, userId });
|
|
}
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
log('error', 'tab group close failed', { reqId: req.reqId, error: err.message });
|
|
handleRouteError(err, req, res);
|
|
}
|
|
});
|
|
|
|
// List trace files for a session
|
|
/**
|
|
* @openapi
|
|
* /sessions/{userId}/traces:
|
|
* get:
|
|
* tags: [Sessions]
|
|
* summary: List trace files
|
|
* description: Returns all Playwright trace zip files for the given user session, sorted newest first.
|
|
* security:
|
|
* - BearerAuth: []
|
|
* parameters:
|
|
* - name: userId
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* description: Session owner identifier.
|
|
* responses:
|
|
* 200:
|
|
* description: Trace list.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* traces:
|
|
* type: array
|
|
* items:
|
|
* type: object
|
|
* properties:
|
|
* filename:
|
|
* type: string
|
|
* sizeBytes:
|
|
* type: integer
|
|
* createdAt:
|
|
* type: number
|
|
* modifiedAt:
|
|
* type: number
|
|
* 403:
|
|
* description: Forbidden.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 500:
|
|
* description: Server error.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
app.get('/sessions/:userId/traces', authMiddleware(), async (req, res) => {
|
|
try {
|
|
const userId = normalizeUserId(req.params.userId);
|
|
const traces = await listUserTraces(CONFIG.tracesDir, userId);
|
|
res.json({ traces });
|
|
} catch (err) {
|
|
log('error', 'list traces failed', { error: err.message });
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Stream one trace file
|
|
/**
|
|
* @openapi
|
|
* /sessions/{userId}/traces/{filename}:
|
|
* get:
|
|
* tags: [Sessions]
|
|
* summary: Download a trace file
|
|
* description: Streams a Playwright trace zip for viewing in trace.playwright.dev.
|
|
* security:
|
|
* - BearerAuth: []
|
|
* parameters:
|
|
* - name: userId
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* description: Session owner identifier.
|
|
* - name: filename
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* description: Trace zip filename.
|
|
* responses:
|
|
* 200:
|
|
* description: Trace zip stream.
|
|
* content:
|
|
* application/zip:
|
|
* schema:
|
|
* type: string
|
|
* format: binary
|
|
* 400:
|
|
* description: Invalid filename.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 404:
|
|
* description: Trace not found.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 403:
|
|
* description: Forbidden.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 500:
|
|
* description: Server error.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
app.get('/sessions/:userId/traces/:filename', authMiddleware(), async (req, res) => {
|
|
try {
|
|
const userId = normalizeUserId(req.params.userId);
|
|
const full = resolveTracePath(CONFIG.tracesDir, userId, req.params.filename);
|
|
if (!full) return res.status(400).json({ error: 'invalid filename' });
|
|
const st = await statTrace(full);
|
|
if (!st) return res.status(404).json({ error: 'not found' });
|
|
res.setHeader('Content-Type', 'application/zip');
|
|
res.setHeader('Content-Length', String(st.size));
|
|
const stream = fs.createReadStream(full);
|
|
stream.on('error', (err) => {
|
|
if (!res.headersSent) res.status(404).json({ error: 'not found' });
|
|
else res.destroy();
|
|
});
|
|
stream.pipe(res);
|
|
} catch (err) {
|
|
log('error', 'stream trace failed', { error: err.message });
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Delete one trace file
|
|
/**
|
|
* @openapi
|
|
* /sessions/{userId}/traces/{filename}:
|
|
* delete:
|
|
* tags: [Sessions]
|
|
* summary: Delete a trace file
|
|
* description: Removes a specific Playwright trace zip from the server.
|
|
* security:
|
|
* - BearerAuth: []
|
|
* parameters:
|
|
* - name: userId
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* description: Session owner identifier.
|
|
* - name: filename
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* description: Trace zip filename.
|
|
* responses:
|
|
* 200:
|
|
* description: Trace deleted.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* ok:
|
|
* type: boolean
|
|
* 400:
|
|
* description: Invalid filename.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 404:
|
|
* description: Trace not found.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 403:
|
|
* description: Forbidden.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 500:
|
|
* description: Server error.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
app.delete('/sessions/:userId/traces/:filename', authMiddleware(), async (req, res) => {
|
|
try {
|
|
const userId = normalizeUserId(req.params.userId);
|
|
const full = resolveTracePath(CONFIG.tracesDir, userId, req.params.filename);
|
|
if (!full) return res.status(400).json({ error: 'invalid filename' });
|
|
try {
|
|
await deleteTrace(full);
|
|
} catch (err) {
|
|
if (err.code === 'ENOENT') return res.status(404).json({ error: 'not found' });
|
|
throw err;
|
|
}
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
log('error', 'delete trace failed', { error: err.message });
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Close session
|
|
/**
|
|
* @openapi
|
|
* /sessions/{userId}:
|
|
* delete:
|
|
* tags: [Sessions]
|
|
* summary: Destroy a user session
|
|
* description: Closes all tabs and cleans up state for the given userId.
|
|
* parameters:
|
|
* - name: userId
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Session destroyed.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* ok:
|
|
* type: boolean
|
|
* closed:
|
|
* type: integer
|
|
* 404:
|
|
* description: Session not found.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
app.delete('/sessions/:userId', authMiddleware(), async (req, res) => {
|
|
try {
|
|
const userId = normalizeUserId(req.params.userId);
|
|
const session = sessions.get(userId);
|
|
if (session) {
|
|
await closeSession(userId, session, { reason: 'api_delete_session', clearDownloads: true, clearLocks: true });
|
|
log('info', 'session closed', { userId });
|
|
}
|
|
if (sessions.size === 0) scheduleBrowserIdleShutdown();
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
log('error', 'session close failed', { error: err.message });
|
|
handleRouteError(err, req, res);
|
|
}
|
|
});
|
|
|
|
// Cleanup stale sessions
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [userId, session] of Array.from(sessions.entries())) {
|
|
if (now - session.lastAccess > SESSION_TIMEOUT_MS) {
|
|
session._closing = true;
|
|
const idleMs = now - session.lastAccess;
|
|
sessionsExpiredTotal.inc();
|
|
pluginEvents.emit('session:expired', { userId, idleMs });
|
|
closeSession(userId, session, { reason: 'session_timeout', clearDownloads: true, clearLocks: true }).catch(() => {});
|
|
log('info', 'session expired', { userId });
|
|
}
|
|
}
|
|
// When all sessions gone, start idle timer to kill browser
|
|
if (sessions.size === 0) {
|
|
scheduleBrowserIdleShutdown();
|
|
}
|
|
refreshTabLockQueueDepth();
|
|
}, 60_000);
|
|
|
|
// Memory pressure eviction (Fly.io only) — evict oldest session when RAM is high.
|
|
// Prevents Camoufox OOM by proactively freeing BrowserContexts.
|
|
if (FLY_MACHINE_ID) {
|
|
const MEMORY_HIGH_WATERMARK = 0.80;
|
|
setInterval(() => {
|
|
let totalMem = os.totalmem();
|
|
let freeMem = os.freemem();
|
|
let usedRatio = 1 - (freeMem / totalMem);
|
|
|
|
// Evict sessions in a loop until memory drops below the watermark
|
|
// or no more sessions remain. closeSession is async but memory
|
|
// reclamation (context.close → Firefox frees pages) starts immediately.
|
|
let evicted = 0;
|
|
while (usedRatio >= MEMORY_HIGH_WATERMARK && sessions.size > 0) {
|
|
let oldestKey = null;
|
|
let oldestAccess = Infinity;
|
|
for (const [key, session] of sessions) {
|
|
if (session._closing) continue;
|
|
if (session.lastAccess < oldestAccess) {
|
|
oldestAccess = session.lastAccess;
|
|
oldestKey = key;
|
|
}
|
|
}
|
|
if (!oldestKey) break;
|
|
const session = sessions.get(oldestKey);
|
|
const idleMs = Date.now() - session.lastAccess;
|
|
log('warn', 'memory pressure eviction', {
|
|
userId: oldestKey,
|
|
usedPct: (usedRatio * 100).toFixed(1),
|
|
freeMb: Math.round(freeMem / 1048576),
|
|
idleMs,
|
|
sessions: sessions.size,
|
|
});
|
|
session._closing = true;
|
|
sessionsExpiredTotal.inc();
|
|
closeSession(oldestKey, session, {
|
|
reason: 'memory_pressure', clearDownloads: true, clearLocks: true,
|
|
}).catch(() => {});
|
|
evicted++;
|
|
// Re-check after marking session for closure
|
|
freeMem = os.freemem();
|
|
usedRatio = 1 - (freeMem / totalMem);
|
|
}
|
|
}, 30_000);
|
|
}
|
|
|
|
// Per-tab inactivity reaper — close tabs idle for TAB_INACTIVITY_MS
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [userId, session] of sessions) {
|
|
for (const [listItemId, group] of session.tabGroups) {
|
|
for (const [tabId, tabState] of group) {
|
|
if (!tabState._lastReaperCheck) {
|
|
tabState._lastReaperCheck = now;
|
|
tabState._lastReaperToolCalls = tabState.toolCalls;
|
|
continue;
|
|
}
|
|
if (tabState.toolCalls === tabState._lastReaperToolCalls) {
|
|
const idleMs = now - tabState._lastReaperCheck;
|
|
if (idleMs >= TAB_INACTIVITY_MS) {
|
|
tabsReapedTotal.inc();
|
|
log('info', 'tab reaped (inactive)', { userId, tabId, listItemId, idleMs, toolCalls: tabState.toolCalls });
|
|
safePageClose(tabState.page);
|
|
group.delete(tabId);
|
|
{ const _l = tabLocks.get(tabId); if (_l) _l.drain(); tabLocks.delete(tabId); }
|
|
refreshTabLockQueueDepth();
|
|
refreshActiveTabsGauge();
|
|
}
|
|
} else {
|
|
tabState._lastReaperCheck = now;
|
|
tabState._lastReaperToolCalls = tabState.toolCalls;
|
|
}
|
|
}
|
|
if (group.size === 0) {
|
|
session.tabGroups.delete(listItemId);
|
|
}
|
|
}
|
|
// Clean up sessions with zero tabs remaining -- free browser context memory
|
|
if (session.tabGroups.size === 0) {
|
|
session._closing = true;
|
|
log('info', 'session empty after tab reaper, closing', { userId });
|
|
closeSession(userId, session, { reason: 'tab_reaper_empty_session', clearDownloads: true, clearLocks: true }).catch(() => {});
|
|
sessionsExpiredTotal.inc();
|
|
}
|
|
}
|
|
if (sessions.size === 0) scheduleBrowserIdleShutdown();
|
|
}, 60_000);
|
|
|
|
// Orphan page reaper -- force-closes Playwright pages that survived a safePageClose
|
|
// timeout or were otherwise dropped from tabGroups tracking. Without this, leaked
|
|
// pages starve Firefox of DOM threads and eventually block new tab creation.
|
|
setInterval(() => {
|
|
let reaped = 0;
|
|
for (const session of sessions.values()) {
|
|
if (session._closing) continue;
|
|
let contextPages;
|
|
try {
|
|
contextPages = session.context.pages();
|
|
} catch (_) {
|
|
continue; // context already dead
|
|
}
|
|
const registered = new Set();
|
|
for (const group of session.tabGroups.values()) {
|
|
for (const tabState of group.values()) registered.add(tabState.page);
|
|
}
|
|
for (const page of contextPages) {
|
|
if (!registered.has(page)) {
|
|
reaped++;
|
|
page.removeAllListeners();
|
|
page.close({ runBeforeUnload: false }).catch(() => {});
|
|
}
|
|
}
|
|
}
|
|
if (reaped > 0) log('warn', 'orphan page reaper closed leaked pages', { reaped });
|
|
}, 60_000);
|
|
|
|
// Idle memory pressure restart -- when all sessions are gone, kill the browser
|
|
// process immediately if either Node native memory or the Camoufox process tree
|
|
// is large. This prevents idle Firefox children from holding most of the VM RAM
|
|
// while Node reports zero sessions/tabs.
|
|
setInterval(() => {
|
|
if (sessions.size > 0 || !browser) return;
|
|
const mem = process.memoryUsage();
|
|
const nativeMemMb = Math.round((mem.rss - mem.heapUsed) / 1048576);
|
|
const browserRssMb = browserProcessTreeRssMb(_browserPid());
|
|
|
|
if (browserRssMb !== null && browserRssMb >= CONFIG.browserRssRestartThresholdMb) {
|
|
log('warn', 'browser rss pressure, restarting browser', {
|
|
browserRssMb,
|
|
thresholdMb: CONFIG.browserRssRestartThresholdMb,
|
|
});
|
|
browserRestartsTotal.labels('browser_rss_pressure').inc();
|
|
closeBrowserFully('browser_rss_pressure').catch((err) => {
|
|
log('error', 'browser rss pressure browser close failed', { error: err.message });
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (_nativeMemBaseline === null) {
|
|
_nativeMemBaseline = nativeMemMb;
|
|
return;
|
|
}
|
|
const growth = nativeMemMb - _nativeMemBaseline;
|
|
if (growth >= NATIVE_MEM_RESTART_THRESHOLD_MB) {
|
|
log('warn', 'native memory pressure, restarting browser', {
|
|
baselineMb: _nativeMemBaseline,
|
|
currentMb: nativeMemMb,
|
|
growthMb: growth,
|
|
thresholdMb: NATIVE_MEM_RESTART_THRESHOLD_MB,
|
|
});
|
|
browserRestartsTotal.labels('memory_pressure').inc();
|
|
closeBrowserFully('memory_pressure').catch((err) => {
|
|
log('error', 'memory pressure browser close failed', { error: err.message });
|
|
});
|
|
}
|
|
}, 30_000);
|
|
|
|
// =============================================================================
|
|
// OpenClaw-compatible endpoint aliases
|
|
// These allow camoufox to be used as a profile backend for OpenClaw's browser tool
|
|
// =============================================================================
|
|
|
|
// GET / - Status (passive -- does not launch browser)
|
|
/**
|
|
* @openapi
|
|
* /:
|
|
* get:
|
|
* tags: [System]
|
|
* summary: Server status
|
|
* description: Returns basic server liveness and browser state.
|
|
* responses:
|
|
* 200:
|
|
* description: Server status.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* ok:
|
|
* type: boolean
|
|
* enabled:
|
|
* type: boolean
|
|
* running:
|
|
* type: boolean
|
|
* engine:
|
|
* type: string
|
|
* browserConnected:
|
|
* type: boolean
|
|
* browserRunning:
|
|
* type: boolean
|
|
*/
|
|
app.get('/', (req, res) => {
|
|
const running = browser !== null && (browser.isConnected?.() ?? false);
|
|
res.json({
|
|
ok: true,
|
|
enabled: true,
|
|
running,
|
|
engine: 'camoufox',
|
|
browserConnected: running,
|
|
browserRunning: running,
|
|
});
|
|
});
|
|
|
|
// GET /tabs - List all tabs (OpenClaw expects this)
|
|
/**
|
|
* @openapi
|
|
* /tabs:
|
|
* get:
|
|
* tags: [Tabs]
|
|
* summary: List open tabs
|
|
* description: Returns all tabs for a given userId.
|
|
* parameters:
|
|
* - name: userId
|
|
* in: query
|
|
* schema:
|
|
* type: string
|
|
* description: Filter by session owner.
|
|
* responses:
|
|
* 200:
|
|
* description: Tab list.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* running:
|
|
* type: boolean
|
|
* tabs:
|
|
* type: array
|
|
* items:
|
|
* type: object
|
|
* properties:
|
|
* tabId:
|
|
* type: string
|
|
* targetId:
|
|
* type: string
|
|
* url:
|
|
* type: string
|
|
* title:
|
|
* type: string
|
|
* listItemId:
|
|
* type: string
|
|
*/
|
|
app.get('/tabs', async (req, res) => {
|
|
try {
|
|
const userId = req.query.userId;
|
|
const session = sessions.get(normalizeUserId(userId));
|
|
|
|
if (!session) {
|
|
return res.json({ running: true, tabs: [] });
|
|
}
|
|
|
|
const tabs = [];
|
|
for (const [listItemId, group] of session.tabGroups) {
|
|
for (const [tabId, tabState] of group) {
|
|
tabs.push({
|
|
targetId: tabId,
|
|
tabId,
|
|
url: tabState.page.url(),
|
|
title: await tabState.page.title().catch(() => ''),
|
|
listItemId
|
|
});
|
|
}
|
|
}
|
|
|
|
res.json({ running: true, tabs });
|
|
} catch (err) {
|
|
log('error', 'list tabs failed', { reqId: req.reqId, error: err.message });
|
|
handleRouteError(err, req, res);
|
|
}
|
|
});
|
|
|
|
// POST /tabs/open - Open tab (alias for POST /tabs, OpenClaw format)
|
|
/**
|
|
* @openapi
|
|
* /tabs/open:
|
|
* post:
|
|
* tags: [Legacy]
|
|
* summary: Open tab (OpenClaw format)
|
|
* deprecated: true
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [userId, url]
|
|
* properties:
|
|
* userId:
|
|
* type: string
|
|
* url:
|
|
* type: string
|
|
* listItemId:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Tab opened.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* 400:
|
|
* description: Bad request.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
app.post('/tabs/open', async (req, res) => {
|
|
try {
|
|
const { url, userId, listItemId = 'default' } = req.body;
|
|
if (!userId) {
|
|
return res.status(400).json({ error: 'userId is required' });
|
|
}
|
|
if (!url) {
|
|
return res.status(400).json({ error: 'url is required' });
|
|
}
|
|
|
|
const urlErr = validateUrl(url);
|
|
if (urlErr) return res.status(400).json({ error: urlErr });
|
|
|
|
let session = await getSession(userId);
|
|
|
|
// Recycle oldest tab when limits are reached instead of rejecting
|
|
let totalTabs = 0;
|
|
for (const g of session.tabGroups.values()) totalTabs += g.size;
|
|
if (totalTabs >= MAX_TABS_PER_SESSION || getTotalTabCount() >= MAX_TABS_GLOBAL) {
|
|
const recycled = await recycleOldestTab(session, req.reqId, userId);
|
|
if (!recycled) {
|
|
return res.status(429).json({ error: 'Maximum tabs per session reached' });
|
|
}
|
|
}
|
|
|
|
let group = getTabGroup(session, listItemId);
|
|
|
|
let page = await session.context.newPage();
|
|
const tabId = fly.makeTabId();
|
|
let tabState = createTabState(page);
|
|
attachDownloadListener(tabState, tabId, log, pluginEvents, userId);
|
|
group.set(tabId, tabState);
|
|
attachPopupHandler(page, userId, listItemId);
|
|
refreshActiveTabsGauge();
|
|
|
|
try {
|
|
await withPageLoadDuration('open_url', () => page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }));
|
|
} catch (navErr) {
|
|
if ((isProxyError(navErr) || isTimeoutError(navErr)) && proxyPool?.canRotateSessions) {
|
|
log('warn', 'tab open failed, retrying with fresh proxy', {
|
|
reqId: req.reqId, tabId, error: navErr.message,
|
|
});
|
|
browserRestartsTotal.labels('proxy_retry').inc();
|
|
const key = normalizeUserId(userId);
|
|
const oldSession = sessions.get(key);
|
|
if (oldSession) {
|
|
await closeSession(key, oldSession, { reason: 'proxy_retry_rotate', clearDownloads: true, clearLocks: true });
|
|
}
|
|
session = await getSession(userId);
|
|
group = getTabGroup(session, listItemId);
|
|
page = await session.context.newPage();
|
|
tabState = createTabState(page);
|
|
attachDownloadListener(tabState, tabId, log, pluginEvents, userId);
|
|
group.set(tabId, tabState);
|
|
attachPopupHandler(page, userId, listItemId);
|
|
refreshActiveTabsGauge();
|
|
await withPageLoadDuration('open_url', () => page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }));
|
|
} else {
|
|
throw navErr;
|
|
}
|
|
}
|
|
tabState.visitedUrls.add(url);
|
|
|
|
log('info', 'openclaw tab opened', { reqId: req.reqId, tabId, url: page.url() });
|
|
res.json({
|
|
ok: true,
|
|
targetId: tabId,
|
|
tabId,
|
|
url: page.url(),
|
|
title: await page.title().catch(() => '')
|
|
});
|
|
} catch (err) {
|
|
log('error', 'openclaw tab open failed', { reqId: req.reqId, error: err.message });
|
|
handleRouteError(err, req, res);
|
|
}
|
|
});
|
|
|
|
// POST /start - Start browser (OpenClaw expects this)
|
|
/**
|
|
* @openapi
|
|
* /start:
|
|
* post:
|
|
* tags: [Browser]
|
|
* summary: Start browser
|
|
* description: Ensures the browser process is running. Idempotent.
|
|
* responses:
|
|
* 200:
|
|
* description: Browser started.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* ok:
|
|
* type: boolean
|
|
* profile:
|
|
* type: string
|
|
* 500:
|
|
* description: Launch failed.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
app.post('/start', async (req, res) => {
|
|
try {
|
|
await ensureBrowser();
|
|
res.json({ ok: true, profile: 'camoufox' });
|
|
} catch (err) {
|
|
failuresTotal.labels('browser_launch', 'start').inc();
|
|
res.status(500).json({ ok: false, error: safeError(err) });
|
|
}
|
|
});
|
|
|
|
// POST /stop - Stop browser (OpenClaw expects this)
|
|
/**
|
|
* @openapi
|
|
* /stop:
|
|
* post:
|
|
* tags: [Browser]
|
|
* summary: Stop browser
|
|
* description: Stops the browser and closes all sessions. Requires x-admin-key header.
|
|
* security:
|
|
* - BearerAuth: []
|
|
* responses:
|
|
* 200:
|
|
* description: Browser stopped.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* ok:
|
|
* type: boolean
|
|
* stopped:
|
|
* type: boolean
|
|
* profile:
|
|
* type: string
|
|
* 403:
|
|
* description: Forbidden.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
app.post('/stop', async (req, res) => {
|
|
try {
|
|
const adminKey = req.headers['x-admin-key'];
|
|
if (!adminKey || !timingSafeCompare(adminKey, CONFIG.adminKey)) {
|
|
return res.status(403).json({ error: 'Forbidden' });
|
|
}
|
|
await closeAllSessions('admin_stop', { clearDownloads: true, clearLocks: true });
|
|
await closeBrowserFully('admin_stop');
|
|
res.json({ ok: true, stopped: true, profile: 'camoufox' });
|
|
} catch (err) {
|
|
res.status(500).json({ ok: false, error: safeError(err) });
|
|
}
|
|
});
|
|
|
|
// POST /navigate - Navigate (OpenClaw format with targetId in body)
|
|
/**
|
|
* @openapi
|
|
* /navigate:
|
|
* post:
|
|
* tags: [Legacy]
|
|
* summary: Navigate (OpenClaw format)
|
|
* description: Navigate with targetId in body instead of path param.
|
|
* deprecated: true
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [userId, url]
|
|
* properties:
|
|
* userId:
|
|
* type: string
|
|
* targetId:
|
|
* type: string
|
|
* url:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Navigation result.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* 400:
|
|
* description: Bad request.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 404:
|
|
* description: Tab not found.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
app.post('/navigate', async (req, res) => {
|
|
try {
|
|
const { targetId, url, userId } = req.body;
|
|
if (!userId) {
|
|
return res.status(400).json({ error: 'userId is required' });
|
|
}
|
|
if (!url) {
|
|
return res.status(400).json({ error: 'url is required' });
|
|
}
|
|
|
|
const urlErr = validateUrl(url);
|
|
if (urlErr) return res.status(400).json({ error: urlErr });
|
|
|
|
const session = sessions.get(normalizeUserId(userId));
|
|
const found = session && findTab(session, targetId);
|
|
if (!found) {
|
|
return tabNotFoundResponse(res, req.params.tabId || targetId);
|
|
}
|
|
|
|
const { tabState } = found;
|
|
tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
|
|
|
|
const result = await withTabLock(targetId, async () => {
|
|
await withPageLoadDuration('navigate', () => tabState.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }));
|
|
tabState.visitedUrls.add(url);
|
|
tabState.lastSnapshot = null;
|
|
|
|
// Google SERP: defer extraction to snapshot call
|
|
if (isGoogleSerp(tabState.page.url())) {
|
|
tabState.refs = new Map();
|
|
return { ok: true, targetId, url: tabState.page.url(), googleSerp: true };
|
|
}
|
|
|
|
tabState.refs = await buildRefs(tabState.page);
|
|
return { ok: true, targetId, url: tabState.page.url() };
|
|
});
|
|
|
|
res.json(result);
|
|
} catch (err) {
|
|
log('error', 'openclaw navigate failed', { reqId: req.reqId, error: err.message });
|
|
handleRouteError(err, req, res);
|
|
}
|
|
});
|
|
|
|
// GET /snapshot - Snapshot (OpenClaw format with query params)
|
|
/**
|
|
* @openapi
|
|
* /snapshot:
|
|
* get:
|
|
* tags: [Legacy]
|
|
* summary: Snapshot (OpenClaw format)
|
|
* description: Snapshot with targetId/userId as query params.
|
|
* deprecated: true
|
|
* parameters:
|
|
* - name: targetId
|
|
* in: query
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* - name: userId
|
|
* in: query
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* - name: format
|
|
* in: query
|
|
* schema:
|
|
* type: string
|
|
* - name: offset
|
|
* in: query
|
|
* schema:
|
|
* type: integer
|
|
* - name: includeScreenshot
|
|
* in: query
|
|
* schema:
|
|
* type: string
|
|
* enum: ['true', 'false']
|
|
* responses:
|
|
* 200:
|
|
* description: Snapshot.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* 400:
|
|
* description: Bad request.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 404:
|
|
* description: Tab not found.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
app.get('/snapshot', async (req, res) => {
|
|
try {
|
|
const { targetId, userId, format = 'text' } = req.query;
|
|
const offset = parseInt(req.query.offset) || 0;
|
|
if (!userId) {
|
|
return res.status(400).json({ error: 'userId is required' });
|
|
}
|
|
|
|
const session = sessions.get(normalizeUserId(userId));
|
|
const found = session && findTab(session, targetId);
|
|
if (!found) {
|
|
return tabNotFoundResponse(res, req.params.tabId || targetId);
|
|
}
|
|
|
|
const { tabState } = found;
|
|
tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
|
|
|
|
// Cached chunk retrieval
|
|
if (offset > 0 && tabState.lastSnapshot) {
|
|
const win = windowSnapshot(tabState.lastSnapshot, offset);
|
|
const response = { ok: true, format: 'aria', targetId, url: tabState.page.url(), snapshot: win.text, refsCount: tabState.refs.size, truncated: win.truncated, totalChars: win.totalChars, hasMore: win.hasMore, nextOffset: win.nextOffset };
|
|
if (req.query.includeScreenshot === 'true') {
|
|
const pngBuffer = await tabState.page.screenshot({ type: 'png' });
|
|
response.screenshot = { data: pngBuffer.toString('base64'), mimeType: 'image/png' };
|
|
}
|
|
return res.json(response);
|
|
}
|
|
|
|
const pageUrl = tabState.page.url();
|
|
|
|
// Google SERP fast path
|
|
if (isGoogleSerp(pageUrl)) {
|
|
const { refs: googleRefs, snapshot: googleSnapshot } = await extractGoogleSerp(tabState.page);
|
|
tabState.refs = googleRefs;
|
|
tabState.lastSnapshot = googleSnapshot;
|
|
snapshotBytes.labels('google_serp').observe(Buffer.byteLength(googleSnapshot, 'utf8'));
|
|
const annotatedYaml = googleSnapshot;
|
|
const win = windowSnapshot(annotatedYaml, 0);
|
|
const response = {
|
|
ok: true, format: 'aria', targetId, url: pageUrl,
|
|
snapshot: win.text, refsCount: tabState.refs.size,
|
|
truncated: win.truncated, totalChars: win.totalChars,
|
|
hasMore: win.hasMore, nextOffset: win.nextOffset,
|
|
};
|
|
if (req.query.includeScreenshot === 'true') {
|
|
const pngBuffer = await tabState.page.screenshot({ type: 'png' });
|
|
response.screenshot = { data: pngBuffer.toString('base64'), mimeType: 'image/png' };
|
|
}
|
|
return res.json(response);
|
|
}
|
|
|
|
tabState.refs = await buildRefs(tabState.page);
|
|
|
|
const ariaYaml = await getAriaSnapshot(tabState.page);
|
|
|
|
// Annotate YAML with ref IDs
|
|
let annotatedYaml = ariaYaml || '';
|
|
if (annotatedYaml && tabState.refs.size > 0) {
|
|
const refsByKey = new Map();
|
|
for (const [refId, el] of tabState.refs) {
|
|
const key = `${el.role}:${el.name || ''}`;
|
|
if (!refsByKey.has(key)) refsByKey.set(key, refId);
|
|
}
|
|
|
|
const lines = annotatedYaml.split('\n');
|
|
annotatedYaml = lines.map(line => {
|
|
const match = line.match(/^(\s*)-\s+(\w+)(?:\s+"([^"]*)")?/);
|
|
if (match) {
|
|
const [, indent, role, name] = match;
|
|
const key = `${role}:${name || ''}`;
|
|
const refId = refsByKey.get(key);
|
|
if (refId) {
|
|
return line.replace(/^(\s*-\s+\w+)/, `$1 [${refId}]`);
|
|
}
|
|
}
|
|
return line;
|
|
}).join('\n');
|
|
}
|
|
|
|
tabState.lastSnapshot = annotatedYaml;
|
|
if (annotatedYaml) snapshotBytes.labels('full').observe(Buffer.byteLength(annotatedYaml, 'utf8'));
|
|
const win = windowSnapshot(annotatedYaml, 0);
|
|
|
|
const response = {
|
|
ok: true,
|
|
format: 'aria',
|
|
targetId,
|
|
url: tabState.page.url(),
|
|
snapshot: win.text,
|
|
refsCount: tabState.refs.size,
|
|
truncated: win.truncated,
|
|
totalChars: win.totalChars,
|
|
hasMore: win.hasMore,
|
|
nextOffset: win.nextOffset,
|
|
};
|
|
|
|
if (req.query.includeScreenshot === 'true') {
|
|
const pngBuffer = await tabState.page.screenshot({ type: 'png' });
|
|
response.screenshot = { data: pngBuffer.toString('base64'), mimeType: 'image/png' };
|
|
}
|
|
|
|
res.json(response);
|
|
} catch (err) {
|
|
log('error', 'openclaw snapshot failed', { reqId: req.reqId, error: err.message });
|
|
handleRouteError(err, req, res);
|
|
}
|
|
});
|
|
|
|
// POST /act - Combined action endpoint (OpenClaw format)
|
|
// Routes to click/type/scroll/press/etc based on 'kind' parameter
|
|
/**
|
|
* @openapi
|
|
* /act:
|
|
* post:
|
|
* tags: [Legacy]
|
|
* summary: Combined action (OpenClaw format)
|
|
* description: Routes to click/type/scroll/press/etc based on "kind" parameter.
|
|
* deprecated: true
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [userId, kind]
|
|
* properties:
|
|
* userId:
|
|
* type: string
|
|
* kind:
|
|
* type: string
|
|
* description: 'Action kind: click, type, scroll, press, key, select_option, drag, hover, screenshot, wait, back, forward.'
|
|
* targetId:
|
|
* type: string
|
|
* ref:
|
|
* type: string
|
|
* selector:
|
|
* type: string
|
|
* text:
|
|
* type: string
|
|
* key:
|
|
* type: string
|
|
* direction:
|
|
* type: string
|
|
* url:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Action result.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* 400:
|
|
* description: Bad request.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 404:
|
|
* description: Tab not found.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
app.post('/act', async (req, res) => {
|
|
try {
|
|
const { kind, targetId, userId, ...params } = req.body;
|
|
if (!userId) {
|
|
return res.status(400).json({ error: 'userId is required' });
|
|
}
|
|
|
|
if (!kind) {
|
|
return res.status(400).json({ error: 'kind is required' });
|
|
}
|
|
|
|
const session = sessions.get(normalizeUserId(userId));
|
|
const found = session && findTab(session, targetId);
|
|
if (!found) {
|
|
return tabNotFoundResponse(res, req.params.tabId || targetId);
|
|
}
|
|
|
|
const { tabState } = found;
|
|
tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
|
|
|
|
const result = await withTabLock(targetId, async () => {
|
|
switch (kind) {
|
|
case 'click': {
|
|
const { ref, selector, doubleClick } = params;
|
|
if (!ref && !selector) {
|
|
throw new Error('ref or selector required');
|
|
}
|
|
|
|
const doClick = async (locatorOrSelector, isLocator) => {
|
|
const locator = isLocator ? locatorOrSelector : tabState.page.locator(locatorOrSelector);
|
|
const clickOpts = { timeout: 3000 };
|
|
if (doubleClick) clickOpts.clickCount = 2;
|
|
|
|
try {
|
|
await locator.click(clickOpts);
|
|
} catch (err) {
|
|
if (err.message.includes('intercepts pointer events')) {
|
|
await locator.click({ ...clickOpts, force: true });
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
};
|
|
|
|
if (ref) {
|
|
let locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
if (!locator) {
|
|
log('info', 'auto-refreshing refs before click (openclaw)', { ref, hadRefs: tabState.refs.size });
|
|
tabState.refs = await buildRefs(tabState.page);
|
|
locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
}
|
|
if (!locator) { const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none'; throw new StaleRefsError(ref, maxRef, tabState.refs.size); }
|
|
await doClick(locator, true);
|
|
} else {
|
|
await doClick(selector, false);
|
|
}
|
|
|
|
await tabState.page.waitForTimeout(500);
|
|
tabState.refs = await buildRefs(tabState.page);
|
|
return { ok: true, targetId, url: tabState.page.url() };
|
|
}
|
|
|
|
case 'type': {
|
|
const { ref, selector, text, submit, mode = 'fill', delay = 30 } = params;
|
|
if (mode === 'fill' && !ref && !selector) {
|
|
throw new Error('ref or selector required for mode=fill');
|
|
}
|
|
if (typeof text !== 'string') {
|
|
throw new Error('text is required');
|
|
}
|
|
if (mode !== 'fill' && mode !== 'keyboard') {
|
|
throw new Error("mode must be 'fill' or 'keyboard'");
|
|
}
|
|
|
|
let locator = null;
|
|
if (ref) {
|
|
locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
if (!locator) {
|
|
log('info', 'auto-refreshing refs before type (openclaw)', { ref, hadRefs: tabState.refs.size, mode });
|
|
tabState.refs = await buildRefs(tabState.page);
|
|
locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
}
|
|
if (!locator) { const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none'; throw new StaleRefsError(ref, maxRef, tabState.refs.size); }
|
|
}
|
|
|
|
if (mode === 'fill') {
|
|
if (locator) {
|
|
await locator.fill(text, { timeout: 10000 });
|
|
} else {
|
|
await tabState.page.fill(selector, text, { timeout: 10000 });
|
|
}
|
|
} else {
|
|
if (locator) {
|
|
await locator.focus({ timeout: 10000 });
|
|
} else if (selector) {
|
|
await tabState.page.focus(selector, { timeout: 10000 });
|
|
}
|
|
await tabState.page.keyboard.type(text, { delay });
|
|
}
|
|
if (submit) await tabState.page.keyboard.press('Enter');
|
|
return { ok: true, targetId };
|
|
}
|
|
|
|
case 'press': {
|
|
const { key } = params;
|
|
if (!key) throw new Error('key is required');
|
|
await tabState.page.keyboard.press(key);
|
|
return { ok: true, targetId };
|
|
}
|
|
|
|
case 'scroll':
|
|
case 'scrollIntoView': {
|
|
const { ref, direction = 'down', amount = 500 } = params;
|
|
if (ref) {
|
|
let locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
if (!locator) {
|
|
tabState.refs = await buildRefs(tabState.page);
|
|
locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
}
|
|
if (!locator) { const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none'; throw new StaleRefsError(ref, maxRef, tabState.refs.size); }
|
|
await locator.scrollIntoViewIfNeeded({ timeout: 5000 });
|
|
} else {
|
|
const isVertical = direction === 'up' || direction === 'down';
|
|
const delta = (direction === 'up' || direction === 'left') ? -amount : amount;
|
|
await tabState.page.mouse.wheel(isVertical ? 0 : delta, isVertical ? delta : 0);
|
|
}
|
|
await tabState.page.waitForTimeout(300);
|
|
return { ok: true, targetId };
|
|
}
|
|
|
|
case 'hover': {
|
|
const { ref, selector } = params;
|
|
if (!ref && !selector) throw new Error('ref or selector required');
|
|
|
|
if (ref) {
|
|
let locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
if (!locator) {
|
|
tabState.refs = await buildRefs(tabState.page);
|
|
locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
}
|
|
if (!locator) { const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none'; throw new StaleRefsError(ref, maxRef, tabState.refs.size); }
|
|
await locator.hover({ timeout: 5000 });
|
|
} else {
|
|
await tabState.page.locator(selector).hover({ timeout: 5000 });
|
|
}
|
|
return { ok: true, targetId };
|
|
}
|
|
|
|
case 'wait': {
|
|
const { timeMs, text, loadState } = params;
|
|
if (timeMs) {
|
|
await tabState.page.waitForTimeout(timeMs);
|
|
} else if (text) {
|
|
await tabState.page.waitForSelector(`text=${text}`, { timeout: 30000 });
|
|
} else if (loadState) {
|
|
await tabState.page.waitForLoadState(loadState, { timeout: 30000 });
|
|
}
|
|
return { ok: true, targetId, url: tabState.page.url() };
|
|
}
|
|
|
|
case 'close': {
|
|
await safePageClose(tabState.page);
|
|
found.group.delete(targetId);
|
|
{ const _l = tabLocks.get(targetId); if (_l) _l.drain(); tabLocks.delete(targetId); }
|
|
return { ok: true, targetId };
|
|
}
|
|
|
|
default:
|
|
throw new Error(`Unsupported action kind: ${kind}`);
|
|
}
|
|
});
|
|
|
|
res.json(result);
|
|
} catch (err) {
|
|
log('error', 'act failed', { reqId: req.reqId, kind: req.body?.kind, error: err.message });
|
|
handleRouteError(err, req, res);
|
|
}
|
|
});
|
|
|
|
// Periodic stats beacon (every 5 min)
|
|
setInterval(() => {
|
|
const mem = process.memoryUsage();
|
|
let totalTabs = 0;
|
|
for (const [, session] of sessions) {
|
|
for (const [, group] of session.tabGroups) {
|
|
totalTabs += group.size;
|
|
}
|
|
}
|
|
log('info', 'stats', {
|
|
sessions: sessions.size,
|
|
tabs: totalTabs,
|
|
rssBytes: mem.rss,
|
|
heapUsedBytes: mem.heapUsed,
|
|
uptimeSeconds: Math.floor(process.uptime()),
|
|
browserConnected: browser?.isConnected() ?? false,
|
|
});
|
|
}, 5 * 60_000);
|
|
|
|
// Active health probe -- detect hung browser even when isConnected() lies
|
|
setInterval(async () => {
|
|
if (!browser || healthState.isRecovering) return;
|
|
const timeSinceSuccess = Date.now() - healthState.lastSuccessfulNav;
|
|
// Skip probe if operations are in flight AND last success was recent.
|
|
// If it's been >120s since any successful operation, probe anyway --
|
|
// active ops are likely stuck on a frozen browser and will time out eventually.
|
|
if (healthState.activeOps > 0 && timeSinceSuccess < 120000) {
|
|
log('info', 'health probe skipped, operations active', { activeOps: healthState.activeOps });
|
|
return;
|
|
}
|
|
if (timeSinceSuccess < 120000) return;
|
|
|
|
if (healthState.activeOps > 0) {
|
|
log('warn', 'health probe forced despite active ops', { activeOps: healthState.activeOps, timeSinceSuccessMs: timeSinceSuccess });
|
|
}
|
|
|
|
let testContext;
|
|
try {
|
|
testContext = await browser.newContext();
|
|
const page = await testContext.newPage();
|
|
await page.goto('about:blank', { timeout: 5000 });
|
|
await page.close();
|
|
await testContext.close();
|
|
healthState.lastSuccessfulNav = Date.now();
|
|
} catch (err) {
|
|
failuresTotal.labels('health_probe', 'internal').inc();
|
|
log('warn', 'health probe failed', { error: err.message, timeSinceSuccessMs: timeSinceSuccess });
|
|
if (testContext) await testContext.close().catch(() => {});
|
|
restartBrowser('health probe failed').catch(() => {});
|
|
}
|
|
}, 60_000);
|
|
|
|
// Crash logging
|
|
process.on('uncaughtException', (err) => {
|
|
pluginEvents.emit('browser:error', { error: err });
|
|
log('error', 'uncaughtException', { error: err.message, stack: err.stack });
|
|
reporter.reportCrash(err, { resourceOpts: _resourceOpts() });
|
|
sentryCaptureException(err, { type: 'uncaughtException' });
|
|
sentryFlush(2000).finally(() => process.exit(1));
|
|
});
|
|
process.on('unhandledRejection', (reason) => {
|
|
log('error', 'unhandledRejection', { reason: String(reason) });
|
|
if (reason instanceof Error) {
|
|
sentryCaptureException(reason, { type: 'unhandledRejection' });
|
|
}
|
|
});
|
|
|
|
// Graceful shutdown
|
|
let shuttingDown = false;
|
|
|
|
async function gracefulShutdown(signal) {
|
|
if (shuttingDown) return;
|
|
shuttingDown = true;
|
|
log('info', 'shutting down', { signal });
|
|
pluginEvents.emit('server:shutdown', { signal });
|
|
|
|
const forceTimeout = setTimeout(() => {
|
|
log('error', 'shutdown timed out, forcing exit');
|
|
process.exit(1);
|
|
}, 10000);
|
|
forceTimeout.unref();
|
|
|
|
server.close();
|
|
stopMemoryReporter();
|
|
|
|
await closeAllSessions(`shutdown:${signal}`, {
|
|
clearDownloads: false,
|
|
clearLocks: false,
|
|
});
|
|
|
|
await closeBrowserFully(`shutdown:${signal}`);
|
|
await sentryFlush(2000);
|
|
process.exit(0);
|
|
}
|
|
|
|
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
|
|
// Idle self-shutdown REMOVED -- it was racing with min_machines_running=2
|
|
// and stopping machines that Fly couldn't auto-restart fast enough, leaving
|
|
// only 1 machine to handle all browser traffic (causing timeouts for users).
|
|
// Fly's auto_stop_machines=false + min_machines_running=2 handles scaling.
|
|
|
|
const PORT = CONFIG.port;
|
|
pluginEvents.emit('server:starting', { port: PORT });
|
|
|
|
// Load plugins before starting the server
|
|
const pluginCtx = {
|
|
sessions,
|
|
config: CONFIG,
|
|
log,
|
|
events: pluginEvents,
|
|
auth: authMiddleware,
|
|
ensureBrowser,
|
|
getSession,
|
|
destroySession,
|
|
closeSession,
|
|
withUserLimit,
|
|
safePageClose,
|
|
normalizeUserId,
|
|
validateUrl,
|
|
safeError,
|
|
buildProxyUrl,
|
|
proxyPool,
|
|
failuresTotal,
|
|
metricsRegistry: getRegister,
|
|
createMetric,
|
|
/** Factory for Xvfb virtual display. Plugins can replace this to customise resolution/args. */
|
|
createVirtualDisplay: () => new VirtualDisplay(),
|
|
/** The upstream VirtualDisplay class -- plugins can subclass it. */
|
|
VirtualDisplay,
|
|
};
|
|
const loadedPlugins = await loadPlugins(app, pluginCtx);
|
|
|
|
// --- OpenAPI docs (after all routes are registered) ---
|
|
mountDocs(app);
|
|
|
|
// --- Sentry Express error handler (after all routes, before app.listen) ---
|
|
setupSentryErrorHandler(app);
|
|
|
|
const server = app.listen(PORT, async () => {
|
|
startMemoryReporter();
|
|
refreshActiveTabsGauge();
|
|
refreshTabLockQueueDepth();
|
|
pluginEvents.emit('server:started', { port: PORT, pid: process.pid, plugins: loadedPlugins });
|
|
if (FLY_MACHINE_ID) {
|
|
log('info', 'server started (fly)', { port: PORT, pid: process.pid, machineId: FLY_MACHINE_ID, nodeVersion: process.version });
|
|
} else {
|
|
log('info', 'server started', { port: PORT, pid: process.pid, nodeVersion: process.version });
|
|
}
|
|
const tmpCleanup = cleanupOrphanedTempFiles({ tmpDir: os.tmpdir() });
|
|
if (tmpCleanup.removed > 0) {
|
|
log('info', 'cleaned up orphaned camoufox temp files', tmpCleanup);
|
|
}
|
|
const profileCleanup = cleanupStaleFirefoxProfiles();
|
|
if (profileCleanup.removed > 0) {
|
|
log('info', 'cleaned up stale firefox profiles on startup', profileCleanup);
|
|
}
|
|
|
|
// Periodic temp profile cleanup every 10 minutes
|
|
setInterval(() => {
|
|
try {
|
|
const cleaned = cleanupStaleFirefoxProfiles();
|
|
if (cleaned.removed > 0) {
|
|
log('info', 'periodic firefox profile cleanup', cleaned);
|
|
}
|
|
} catch { /* best effort */ }
|
|
}, 10 * 60 * 1000).unref();
|
|
const traceSweep = sweepOldTraces({
|
|
baseDir: CONFIG.tracesDir,
|
|
ttlMs: CONFIG.tracesTtlHours * 3600 * 1000,
|
|
maxBytesPerFile: CONFIG.tracesMaxBytes,
|
|
});
|
|
if (traceSweep.removedTtl > 0 || traceSweep.removedOversized > 0) {
|
|
log('info', 'swept old traces', traceSweep);
|
|
}
|
|
// Pre-warm browser so first request doesn't eat a 6-7s cold start
|
|
try {
|
|
const start = Date.now();
|
|
await ensureBrowser();
|
|
log('info', 'browser pre-warmed', { ms: Date.now() - start });
|
|
scheduleBrowserIdleShutdown();
|
|
} catch (err) {
|
|
if (isFatalInstallError(err)) {
|
|
log('error', 'browser pre-warm aborted: Camoufox binaries are not installed', {
|
|
error: err.message,
|
|
remediation: camoufoxInstallRemediation(),
|
|
});
|
|
} else {
|
|
log('error', 'browser pre-warm failed (will retry in background)', { error: err.message });
|
|
scheduleBrowserWarmRetry();
|
|
}
|
|
}
|
|
// Idle self-shutdown removed -- Fly manages machine lifecycle via fly.toml.
|
|
});
|
|
|
|
server.on('error', (err) => {
|
|
if (err.code === 'EADDRINUSE') {
|
|
log('error', 'port in use', { port: PORT });
|
|
process.exit(1);
|
|
}
|
|
log('error', 'server error', { error: err.message });
|
|
process.exit(1);
|
|
});
|