@askjo/camofox-browser 1.3.1 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/lib/config.js +5 -4
- package/lib/cookies.js +3 -3
- package/lib/downloads.js +240 -0
- package/lib/launcher.js +3 -3
- package/lib/macros.js +1 -1
- package/lib/metrics.js +99 -0
- package/lib/proxy.js +19 -0
- package/lib/snapshot.js +1 -1
- package/lib/youtube.js +160 -51
- package/openclaw.plugin.json +1 -1
- package/package.json +10 -5
- package/plugin.ts +23 -0
- package/scripts/sync-version.js +25 -0
- package/server.js +980 -163
package/server.js
CHANGED
|
@@ -1,12 +1,27 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
1
|
+
import { Camoufox, launchOptions } from 'camoufox-js';
|
|
2
|
+
import { firefox } from 'playwright-core';
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import crypto from 'crypto';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import { expandMacro } from './lib/macros.js';
|
|
7
|
+
import { loadConfig } from './lib/config.js';
|
|
8
|
+
import { normalizePlaywrightProxy } from './lib/proxy.js';
|
|
9
|
+
import { windowSnapshot } from './lib/snapshot.js';
|
|
10
|
+
import {
|
|
11
|
+
MAX_DOWNLOAD_INLINE_BYTES,
|
|
12
|
+
clearTabDownloads,
|
|
13
|
+
clearSessionDownloads,
|
|
14
|
+
attachDownloadListener,
|
|
15
|
+
getDownloadsList,
|
|
16
|
+
extractPageImages,
|
|
17
|
+
} from './lib/downloads.js';
|
|
18
|
+
import { detectYtDlp, hasYtDlp, ytDlpTranscript, parseJson3, parseVtt, parseXml } from './lib/youtube.js';
|
|
19
|
+
import {
|
|
20
|
+
register as metricsRegister,
|
|
21
|
+
requestsTotal, requestDuration, pageLoadDuration,
|
|
22
|
+
activeTabsGauge, tabLockQueueDepth,
|
|
23
|
+
tabLockTimeoutsTotal, startMemoryReporter, actionFromReq,
|
|
24
|
+
} from './lib/metrics.js';
|
|
10
25
|
|
|
11
26
|
const CONFIG = loadConfig();
|
|
12
27
|
|
|
@@ -29,20 +44,34 @@ function log(level, msg, fields = {}) {
|
|
|
29
44
|
const app = express();
|
|
30
45
|
app.use(express.json({ limit: '100kb' }));
|
|
31
46
|
|
|
32
|
-
// Request logging middleware
|
|
47
|
+
// Request logging + metrics middleware
|
|
33
48
|
app.use((req, res, next) => {
|
|
34
|
-
if (req.path === '/health') return next();
|
|
35
49
|
const reqId = crypto.randomUUID().slice(0, 8);
|
|
36
50
|
req.reqId = reqId;
|
|
37
51
|
req.startTime = Date.now();
|
|
52
|
+
|
|
38
53
|
const userId = req.body?.userId || req.query?.userId || '-';
|
|
39
|
-
|
|
54
|
+
if (req.path !== '/health') {
|
|
55
|
+
log('info', 'req', { reqId, method: req.method, path: req.path, userId });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const action = actionFromReq(req);
|
|
59
|
+
const done = requestDuration.startTimer({ action });
|
|
60
|
+
|
|
40
61
|
const origEnd = res.end.bind(res);
|
|
41
62
|
res.end = function (...args) {
|
|
42
63
|
const ms = Date.now() - req.startTime;
|
|
43
|
-
|
|
64
|
+
const isErrorStatus = res.statusCode >= 400;
|
|
65
|
+
requestsTotal.labels(action, isErrorStatus ? 'error' : 'success').inc();
|
|
66
|
+
done();
|
|
67
|
+
|
|
68
|
+
if (req.path !== '/health') {
|
|
69
|
+
log('info', 'res', { reqId, status: res.statusCode, ms });
|
|
70
|
+
}
|
|
71
|
+
|
|
44
72
|
return origEnd(...args);
|
|
45
73
|
};
|
|
74
|
+
|
|
46
75
|
next();
|
|
47
76
|
});
|
|
48
77
|
|
|
@@ -72,6 +101,16 @@ function timingSafeCompare(a, b) {
|
|
|
72
101
|
return crypto.timingSafeEqual(bufA, bufB);
|
|
73
102
|
}
|
|
74
103
|
|
|
104
|
+
// Custom error for stale/unknown element refs — returned as 422 instead of 500
|
|
105
|
+
class StaleRefsError extends Error {
|
|
106
|
+
constructor(ref, maxRef, totalRefs) {
|
|
107
|
+
super(`Unknown ref: ${ref} (valid refs: e1-${maxRef}, ${totalRefs} total). Refs reset after navigation - call snapshot first.`);
|
|
108
|
+
this.name = 'StaleRefsError';
|
|
109
|
+
this.code = 'stale_refs';
|
|
110
|
+
this.ref = ref;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
75
114
|
function safeError(err) {
|
|
76
115
|
if (CONFIG.nodeEnv === 'production') {
|
|
77
116
|
log('error', 'internal error', { error: err.message, stack: err.stack });
|
|
@@ -80,6 +119,17 @@ function safeError(err) {
|
|
|
80
119
|
return err.message;
|
|
81
120
|
}
|
|
82
121
|
|
|
122
|
+
// Send error response with appropriate status code (422 for stale refs, 500 otherwise)
|
|
123
|
+
function sendError(res, err, extraFields = {}) {
|
|
124
|
+
const status = err instanceof StaleRefsError ? 422 : (err.statusCode || 500);
|
|
125
|
+
const body = { error: safeError(err), ...extraFields };
|
|
126
|
+
if (err instanceof StaleRefsError) {
|
|
127
|
+
body.code = 'stale_refs';
|
|
128
|
+
body.ref = err.ref;
|
|
129
|
+
}
|
|
130
|
+
res.status(status).json(body);
|
|
131
|
+
}
|
|
132
|
+
|
|
83
133
|
function validateUrl(url) {
|
|
84
134
|
try {
|
|
85
135
|
const parsed = new URL(url);
|
|
@@ -92,26 +142,38 @@ function validateUrl(url) {
|
|
|
92
142
|
}
|
|
93
143
|
}
|
|
94
144
|
|
|
145
|
+
function isLoopbackAddress(address) {
|
|
146
|
+
if (!address) return false;
|
|
147
|
+
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
|
|
148
|
+
}
|
|
149
|
+
|
|
95
150
|
// Import cookies into a user's browser context (Playwright cookies format)
|
|
96
151
|
// POST /sessions/:userId/cookies { cookies: Cookie[] }
|
|
97
152
|
//
|
|
98
153
|
// SECURITY:
|
|
99
154
|
// Cookie injection moves this from "anonymous browsing" to "authenticated browsing".
|
|
100
|
-
//
|
|
101
|
-
//
|
|
155
|
+
// By default, this endpoint is protected by CAMOFOX_API_KEY.
|
|
156
|
+
// For local development convenience, when CAMOFOX_API_KEY is NOT set, we allow
|
|
157
|
+
// unauthenticated cookie import ONLY from loopback (127.0.0.1 / ::1) and ONLY
|
|
158
|
+
// when NODE_ENV != production.
|
|
102
159
|
app.post('/sessions/:userId/cookies', express.json({ limit: '512kb' }), async (req, res) => {
|
|
103
160
|
try {
|
|
104
|
-
if (
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
161
|
+
if (CONFIG.apiKey) {
|
|
162
|
+
const apiKey = CONFIG.apiKey;
|
|
163
|
+
const auth = String(req.headers['authorization'] || '');
|
|
164
|
+
const match = auth.match(/^Bearer\s+(.+)$/i);
|
|
165
|
+
if (!match || !timingSafeCompare(match[1], apiKey)) {
|
|
166
|
+
return res.status(403).json({ error: 'Forbidden' });
|
|
167
|
+
}
|
|
168
|
+
} else {
|
|
169
|
+
const remoteAddress = req.socket?.remoteAddress || '';
|
|
170
|
+
const allowUnauthedLocal = CONFIG.nodeEnv !== 'production' && isLoopbackAddress(remoteAddress);
|
|
171
|
+
if (!allowUnauthedLocal) {
|
|
172
|
+
return res.status(403).json({
|
|
173
|
+
error:
|
|
174
|
+
'Cookie import is disabled without CAMOFOX_API_KEY except for loopback requests in non-production environments.',
|
|
175
|
+
});
|
|
176
|
+
}
|
|
115
177
|
}
|
|
116
178
|
|
|
117
179
|
const userId = req.params.userId;
|
|
@@ -169,12 +231,13 @@ app.post('/sessions/:userId/cookies', express.json({ limit: '512kb' }), async (r
|
|
|
169
231
|
|
|
170
232
|
let browser = null;
|
|
171
233
|
// userId -> { context, tabGroups: Map<sessionKey, Map<tabId, TabState>>, lastAccess }
|
|
172
|
-
// TabState = { page, refs: Map<refId, {role, name, nth}>, visitedUrls: Set, toolCalls: number }
|
|
234
|
+
// TabState = { page, refs: Map<refId, {role, name, nth}>, visitedUrls: Set, downloads: Array, toolCalls: number }
|
|
173
235
|
// Note: sessionKey was previously called listItemId - both are accepted for backward compatibility
|
|
174
236
|
const sessions = new Map();
|
|
175
237
|
|
|
176
238
|
const SESSION_TIMEOUT_MS = CONFIG.sessionTimeoutMs;
|
|
177
239
|
const MAX_SNAPSHOT_NODES = 500;
|
|
240
|
+
const TAB_INACTIVITY_MS = CONFIG.tabInactivityMs;
|
|
178
241
|
const MAX_SESSIONS = CONFIG.maxSessions;
|
|
179
242
|
const MAX_TABS_PER_SESSION = CONFIG.maxTabsPerSession;
|
|
180
243
|
const MAX_TABS_GLOBAL = CONFIG.maxTabsGlobal;
|
|
@@ -184,39 +247,76 @@ const PAGE_CLOSE_TIMEOUT_MS = 5000;
|
|
|
184
247
|
const NAVIGATE_TIMEOUT_MS = CONFIG.navigateTimeoutMs;
|
|
185
248
|
const BUILDREFS_TIMEOUT_MS = CONFIG.buildrefsTimeoutMs;
|
|
186
249
|
const FAILURE_THRESHOLD = 3;
|
|
187
|
-
const
|
|
250
|
+
const MAX_CONSECUTIVE_TIMEOUTS = 3;
|
|
251
|
+
const TAB_LOCK_TIMEOUT_MS = 35000; // Must be > HANDLER_TIMEOUT_MS so active op times out first
|
|
252
|
+
|
|
253
|
+
// Proper mutex for tab serialization. The old Promise-chain lock on timeout proceeded
|
|
254
|
+
// WITHOUT the lock, allowing concurrent Playwright operations that corrupt CDP state.
|
|
255
|
+
class TabLock {
|
|
256
|
+
constructor() {
|
|
257
|
+
this.queue = [];
|
|
258
|
+
this.active = false;
|
|
259
|
+
}
|
|
188
260
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const
|
|
261
|
+
acquire(timeoutMs) {
|
|
262
|
+
return new Promise((resolve, reject) => {
|
|
263
|
+
const entry = { resolve, reject, timer: null };
|
|
264
|
+
entry.timer = setTimeout(() => {
|
|
265
|
+
const idx = this.queue.indexOf(entry);
|
|
266
|
+
if (idx !== -1) this.queue.splice(idx, 1);
|
|
267
|
+
tabLockTimeoutsTotal.inc();
|
|
268
|
+
refreshTabLockQueueDepth();
|
|
269
|
+
reject(new Error('Tab lock queue timeout'));
|
|
270
|
+
}, timeoutMs);
|
|
271
|
+
this.queue.push(entry);
|
|
272
|
+
refreshTabLockQueueDepth();
|
|
273
|
+
this._tryNext();
|
|
274
|
+
});
|
|
275
|
+
}
|
|
192
276
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
277
|
+
release() {
|
|
278
|
+
this.active = false;
|
|
279
|
+
this._tryNext();
|
|
280
|
+
refreshTabLockQueueDepth();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
_tryNext() {
|
|
284
|
+
if (this.active || this.queue.length === 0) return;
|
|
285
|
+
this.active = true;
|
|
286
|
+
const entry = this.queue.shift();
|
|
287
|
+
clearTimeout(entry.timer);
|
|
288
|
+
refreshTabLockQueueDepth();
|
|
289
|
+
entry.resolve();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
drain() {
|
|
293
|
+
this.active = true;
|
|
294
|
+
for (const entry of this.queue) {
|
|
295
|
+
clearTimeout(entry.timer);
|
|
296
|
+
entry.reject(new Error('Tab destroyed'));
|
|
206
297
|
}
|
|
298
|
+
this.queue = [];
|
|
299
|
+
refreshTabLockQueueDepth();
|
|
207
300
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Per-tab locks to serialize operations on the same tab
|
|
304
|
+
const tabLocks = new Map(); // tabId -> TabLock
|
|
305
|
+
|
|
306
|
+
function getTabLock(tabId) {
|
|
307
|
+
if (!tabLocks.has(tabId)) tabLocks.set(tabId, new TabLock());
|
|
308
|
+
return tabLocks.get(tabId);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Timeout is INSIDE the lock so each operation gets its full budget
|
|
312
|
+
// regardless of how long it waited in the queue.
|
|
313
|
+
async function withTabLock(tabId, operation, timeoutMs = HANDLER_TIMEOUT_MS) {
|
|
314
|
+
const lock = getTabLock(tabId);
|
|
315
|
+
await lock.acquire(TAB_LOCK_TIMEOUT_MS);
|
|
213
316
|
try {
|
|
214
|
-
return await
|
|
317
|
+
return await withTimeout(operation(), timeoutMs, 'action');
|
|
215
318
|
} finally {
|
|
216
|
-
|
|
217
|
-
if (tabLocks.get(tabId) === promise) {
|
|
218
|
-
tabLocks.delete(tabId);
|
|
219
|
-
}
|
|
319
|
+
lock.release();
|
|
220
320
|
}
|
|
221
321
|
}
|
|
222
322
|
|
|
@@ -390,6 +490,7 @@ async function launchBrowserInstance() {
|
|
|
390
490
|
proxy: proxy,
|
|
391
491
|
geoip: !!proxy,
|
|
392
492
|
});
|
|
493
|
+
options.proxy = normalizePlaywrightProxy(options.proxy);
|
|
393
494
|
|
|
394
495
|
browser = await firefox.launch(options);
|
|
395
496
|
log('info', 'camoufox launched');
|
|
@@ -425,6 +526,20 @@ function normalizeUserId(userId) {
|
|
|
425
526
|
async function getSession(userId) {
|
|
426
527
|
const key = normalizeUserId(userId);
|
|
427
528
|
let session = sessions.get(key);
|
|
529
|
+
|
|
530
|
+
// Check if existing session's context is still alive
|
|
531
|
+
if (session) {
|
|
532
|
+
try {
|
|
533
|
+
// Lightweight probe: pages() is synchronous-ish and throws if context is dead
|
|
534
|
+
session.context.pages();
|
|
535
|
+
} catch (err) {
|
|
536
|
+
log('warn', 'session context dead, recreating', { userId: key, error: err.message });
|
|
537
|
+
session.context.close().catch(() => {});
|
|
538
|
+
sessions.delete(key);
|
|
539
|
+
session = null;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
428
543
|
if (!session) {
|
|
429
544
|
if (sessions.size >= MAX_SESSIONS) {
|
|
430
545
|
throw new Error('Maximum concurrent sessions reached');
|
|
@@ -460,6 +575,96 @@ function getTabGroup(session, listItemId) {
|
|
|
460
575
|
return group;
|
|
461
576
|
}
|
|
462
577
|
|
|
578
|
+
function isDeadContextError(err) {
|
|
579
|
+
const msg = err && err.message || '';
|
|
580
|
+
return msg.includes('Target page, context or browser has been closed') ||
|
|
581
|
+
msg.includes('browser has been closed') ||
|
|
582
|
+
msg.includes('Context closed') ||
|
|
583
|
+
msg.includes('Browser closed');
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function isTimeoutError(err) {
|
|
587
|
+
const msg = err && err.message || '';
|
|
588
|
+
return msg.includes('timed out after') ||
|
|
589
|
+
(msg.includes('Timeout') && msg.includes('exceeded'));
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function isTabLockQueueTimeout(err) {
|
|
593
|
+
return err && err.message === 'Tab lock queue timeout';
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function isTabDestroyedError(err) {
|
|
597
|
+
return err && err.message === 'Tab destroyed';
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Centralized error handler for route catch blocks.
|
|
601
|
+
// Auto-destroys dead browser sessions and returns appropriate status codes.
|
|
602
|
+
function handleRouteError(err, req, res, extraFields = {}) {
|
|
603
|
+
const userId = req.body?.userId || req.query?.userId;
|
|
604
|
+
if (userId && isDeadContextError(err)) {
|
|
605
|
+
destroySession(userId);
|
|
606
|
+
}
|
|
607
|
+
// Track consecutive timeouts per tab and auto-destroy stuck tabs
|
|
608
|
+
if (userId && isTimeoutError(err)) {
|
|
609
|
+
const tabId = req.body?.tabId || req.query?.tabId || req.params?.tabId;
|
|
610
|
+
const session = sessions.get(normalizeUserId(userId));
|
|
611
|
+
if (session && tabId) {
|
|
612
|
+
const found = findTab(session, tabId);
|
|
613
|
+
if (found) {
|
|
614
|
+
found.tabState.consecutiveTimeouts++;
|
|
615
|
+
if (found.tabState.consecutiveTimeouts >= MAX_CONSECUTIVE_TIMEOUTS) {
|
|
616
|
+
log('warn', 'auto-destroying tab after consecutive timeouts', { tabId, count: found.tabState.consecutiveTimeouts });
|
|
617
|
+
destroyTab(session, tabId);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
// Lock queue timeout = tab is stuck. Destroy immediately.
|
|
623
|
+
if (userId && isTabLockQueueTimeout(err)) {
|
|
624
|
+
const tabId = req.body?.tabId || req.query?.tabId || req.params?.tabId;
|
|
625
|
+
const session = sessions.get(normalizeUserId(userId));
|
|
626
|
+
if (session && tabId) {
|
|
627
|
+
destroyTab(session, tabId);
|
|
628
|
+
}
|
|
629
|
+
return res.status(503).json({ error: 'Tab unresponsive and has been destroyed. Open a new tab.', ...extraFields });
|
|
630
|
+
}
|
|
631
|
+
// Tab was destroyed while this request was queued in the lock
|
|
632
|
+
if (isTabDestroyedError(err)) {
|
|
633
|
+
return res.status(410).json({ error: 'Tab was destroyed. Open a new tab.', ...extraFields });
|
|
634
|
+
}
|
|
635
|
+
sendError(res, err, extraFields);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function destroyTab(session, tabId) {
|
|
639
|
+
const lock = tabLocks.get(tabId);
|
|
640
|
+
if (lock) {
|
|
641
|
+
lock.drain();
|
|
642
|
+
tabLocks.delete(tabId);
|
|
643
|
+
refreshTabLockQueueDepth();
|
|
644
|
+
}
|
|
645
|
+
for (const [listItemId, group] of session.tabGroups) {
|
|
646
|
+
if (group.has(tabId)) {
|
|
647
|
+
const tabState = group.get(tabId);
|
|
648
|
+
log('warn', 'destroying stuck tab', { tabId, listItemId, toolCalls: tabState.toolCalls });
|
|
649
|
+
safePageClose(tabState.page);
|
|
650
|
+
group.delete(tabId);
|
|
651
|
+
if (group.size === 0) session.tabGroups.delete(listItemId);
|
|
652
|
+
refreshActiveTabsGauge();
|
|
653
|
+
return true;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
return false;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function destroySession(userId) {
|
|
660
|
+
const key = normalizeUserId(userId);
|
|
661
|
+
const session = sessions.get(key);
|
|
662
|
+
if (!session) return;
|
|
663
|
+
log('warn', 'destroying dead session', { userId: key });
|
|
664
|
+
session.context.close().catch(() => {});
|
|
665
|
+
sessions.delete(key);
|
|
666
|
+
}
|
|
667
|
+
|
|
463
668
|
function findTab(session, tabId) {
|
|
464
669
|
for (const [listItemId, group] of session.tabGroups) {
|
|
465
670
|
if (group.has(tabId)) {
|
|
@@ -475,11 +680,36 @@ function createTabState(page) {
|
|
|
475
680
|
page,
|
|
476
681
|
refs: new Map(),
|
|
477
682
|
visitedUrls: new Set(),
|
|
683
|
+
downloads: [],
|
|
478
684
|
toolCalls: 0,
|
|
685
|
+
consecutiveTimeouts: 0,
|
|
479
686
|
lastSnapshot: null,
|
|
480
687
|
};
|
|
481
688
|
}
|
|
482
689
|
|
|
690
|
+
function refreshActiveTabsGauge() {
|
|
691
|
+
activeTabsGauge.set(getTotalTabCount());
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function refreshTabLockQueueDepth() {
|
|
695
|
+
let queued = 0;
|
|
696
|
+
for (const lock of tabLocks.values()) {
|
|
697
|
+
if (lock?.queue) queued += lock.queue.length;
|
|
698
|
+
}
|
|
699
|
+
tabLockQueueDepth.set(queued);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
async function withPageLoadDuration(action, fn) {
|
|
703
|
+
const end = pageLoadDuration.startTimer();
|
|
704
|
+
try {
|
|
705
|
+
return await fn();
|
|
706
|
+
} finally {
|
|
707
|
+
end();
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
|
|
483
713
|
async function waitForPageReady(page, options = {}) {
|
|
484
714
|
const { timeout = 10000, waitForNetwork = true } = options;
|
|
485
715
|
|
|
@@ -569,6 +799,156 @@ async function dismissConsentDialogs(page) {
|
|
|
569
799
|
}
|
|
570
800
|
}
|
|
571
801
|
|
|
802
|
+
// --- Google SERP detection ---
|
|
803
|
+
function isGoogleSerp(url) {
|
|
804
|
+
try {
|
|
805
|
+
const parsed = new URL(url);
|
|
806
|
+
return parsed.hostname.includes('google.') && parsed.pathname === '/search';
|
|
807
|
+
} catch {
|
|
808
|
+
return false;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// --- Google SERP: combined extraction (refs + snapshot in one DOM pass) ---
|
|
813
|
+
// Returns { refs: Map, snapshot: string }
|
|
814
|
+
async function extractGoogleSerp(page) {
|
|
815
|
+
const refs = new Map();
|
|
816
|
+
if (!page || page.isClosed()) return { refs, snapshot: '' };
|
|
817
|
+
|
|
818
|
+
const start = Date.now();
|
|
819
|
+
|
|
820
|
+
const alreadyRendered = await page.evaluate(() => !!document.querySelector('#rso h3, #search h3, #rso [data-snhf]')).catch(() => false);
|
|
821
|
+
if (!alreadyRendered) {
|
|
822
|
+
try {
|
|
823
|
+
await page.waitForSelector('#rso h3, #search h3, #rso [data-snhf]', { timeout: 5000 });
|
|
824
|
+
} catch {
|
|
825
|
+
try {
|
|
826
|
+
await page.waitForSelector('#rso a[href]:not([href^="/search"]), #search a[href]:not([href^="/search"])', { timeout: 2000 });
|
|
827
|
+
} catch {}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const extracted = await page.evaluate(() => {
|
|
832
|
+
const snapshot = [];
|
|
833
|
+
const elements = [];
|
|
834
|
+
let refCounter = 1;
|
|
835
|
+
|
|
836
|
+
function addRef(role, name) {
|
|
837
|
+
const id = 'e' + refCounter++;
|
|
838
|
+
elements.push({ id, role, name });
|
|
839
|
+
return id;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
snapshot.push('- heading "' + document.title.replace(/"/g, '\\"') + '"');
|
|
843
|
+
|
|
844
|
+
const searchInput = document.querySelector('input[name="q"], textarea[name="q"]');
|
|
845
|
+
if (searchInput) {
|
|
846
|
+
const name = 'Search';
|
|
847
|
+
const refId = addRef('searchbox', name);
|
|
848
|
+
snapshot.push('- searchbox "' + name + '" [' + refId + ']: ' + (searchInput.value || ''));
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const navContainer = document.querySelector('div[role="navigation"], div[role="list"]');
|
|
852
|
+
if (navContainer) {
|
|
853
|
+
const navLinks = navContainer.querySelectorAll('a');
|
|
854
|
+
if (navLinks.length > 0) {
|
|
855
|
+
snapshot.push('- navigation:');
|
|
856
|
+
navLinks.forEach(a => {
|
|
857
|
+
const text = (a.textContent || '').trim();
|
|
858
|
+
if (!text || text.length < 1) return;
|
|
859
|
+
if (/^\d+$/.test(text) && parseInt(text) < 50) return;
|
|
860
|
+
const refId = addRef('link', text);
|
|
861
|
+
snapshot.push(' - link "' + text + '" [' + refId + ']');
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const resultContainer = document.querySelector('#rso') || document.querySelector('#search');
|
|
867
|
+
if (resultContainer) {
|
|
868
|
+
const resultBlocks = resultContainer.querySelectorAll(':scope > div');
|
|
869
|
+
for (const block of resultBlocks) {
|
|
870
|
+
const h3 = block.querySelector('h3');
|
|
871
|
+
const mainLink = h3 ? h3.closest('a') : null;
|
|
872
|
+
|
|
873
|
+
if (h3 && mainLink) {
|
|
874
|
+
const title = h3.textContent.trim().replace(/"/g, '\\"');
|
|
875
|
+
const href = mainLink.href;
|
|
876
|
+
const cite = block.querySelector('cite');
|
|
877
|
+
const displayUrl = cite ? cite.textContent.trim() : '';
|
|
878
|
+
|
|
879
|
+
let snippet = '';
|
|
880
|
+
for (const sel of ['[data-sncf]', '[data-content-feature="1"]', '.VwiC3b', 'div[style*="-webkit-line-clamp"]', 'span.aCOpRe']) {
|
|
881
|
+
const el = block.querySelector(sel);
|
|
882
|
+
if (el) { snippet = el.textContent.trim().slice(0, 300); break; }
|
|
883
|
+
}
|
|
884
|
+
if (!snippet) {
|
|
885
|
+
const allText = block.textContent.trim().replace(/\s+/g, ' ');
|
|
886
|
+
const titleLen = title.length + (displayUrl ? displayUrl.length : 0);
|
|
887
|
+
if (allText.length > titleLen + 20) {
|
|
888
|
+
snippet = allText.slice(titleLen).trim().slice(0, 300);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const refId = addRef('link', title);
|
|
893
|
+
snapshot.push('- link "' + title + '" [' + refId + ']:');
|
|
894
|
+
snapshot.push(' - /url: ' + href);
|
|
895
|
+
if (displayUrl) snapshot.push(' - cite: ' + displayUrl);
|
|
896
|
+
if (snippet) snapshot.push(' - text: ' + snippet);
|
|
897
|
+
} else {
|
|
898
|
+
const blockLinks = block.querySelectorAll('a[href^="http"]:not([href*="google.com/search"])');
|
|
899
|
+
if (blockLinks.length > 0) {
|
|
900
|
+
const blockText = block.textContent.trim().replace(/\s+/g, ' ').slice(0, 200);
|
|
901
|
+
if (blockText.length > 10) {
|
|
902
|
+
snapshot.push('- group:');
|
|
903
|
+
snapshot.push(' - text: ' + blockText);
|
|
904
|
+
blockLinks.forEach(a => {
|
|
905
|
+
const linkText = (a.textContent || '').trim().replace(/"/g, '\\"').slice(0, 100);
|
|
906
|
+
if (linkText.length > 2) {
|
|
907
|
+
const refId = addRef('link', linkText);
|
|
908
|
+
snapshot.push(' - link "' + linkText + '" [' + refId + ']:');
|
|
909
|
+
snapshot.push(' - /url: ' + a.href);
|
|
910
|
+
}
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const paaItems = document.querySelectorAll('[jsname="Cpkphb"], div.related-question-pair');
|
|
919
|
+
if (paaItems.length > 0) {
|
|
920
|
+
snapshot.push('- heading "People also ask"');
|
|
921
|
+
paaItems.forEach(q => {
|
|
922
|
+
const text = (q.textContent || '').trim().replace(/"/g, '\\"').slice(0, 150);
|
|
923
|
+
if (text) {
|
|
924
|
+
const refId = addRef('button', text);
|
|
925
|
+
snapshot.push(' - button "' + text + '" [' + refId + ']');
|
|
926
|
+
}
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const nextLink = document.querySelector('#botstuff a[aria-label="Next page"], td.d6cvqb a, a#pnnext');
|
|
931
|
+
if (nextLink) {
|
|
932
|
+
const refId = addRef('link', 'Next');
|
|
933
|
+
snapshot.push('- navigation "pagination":');
|
|
934
|
+
snapshot.push(' - link "Next" [' + refId + ']');
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
return { snapshot: snapshot.join('\n'), elements };
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
const seenCounts = new Map();
|
|
941
|
+
for (const el of extracted.elements) {
|
|
942
|
+
const key = `${el.role}:${el.name}`;
|
|
943
|
+
const nth = seenCounts.get(key) || 0;
|
|
944
|
+
seenCounts.set(key, nth + 1);
|
|
945
|
+
refs.set(el.id, { role: el.role, name: el.name, nth });
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
log('info', 'extractGoogleSerp', { elapsed: Date.now() - start, refs: refs.size });
|
|
949
|
+
return { refs, snapshot: extracted.snapshot };
|
|
950
|
+
}
|
|
951
|
+
|
|
572
952
|
async function buildRefs(page) {
|
|
573
953
|
const refs = new Map();
|
|
574
954
|
|
|
@@ -577,6 +957,13 @@ async function buildRefs(page) {
|
|
|
577
957
|
return refs;
|
|
578
958
|
}
|
|
579
959
|
|
|
960
|
+
// Google SERP fast path — skip ariaSnapshot entirely
|
|
961
|
+
const url = page.url();
|
|
962
|
+
if (isGoogleSerp(url)) {
|
|
963
|
+
const { refs: googleRefs } = await extractGoogleSerp(page);
|
|
964
|
+
return googleRefs;
|
|
965
|
+
}
|
|
966
|
+
|
|
580
967
|
const start = Date.now();
|
|
581
968
|
|
|
582
969
|
// Hard total timeout on the entire buildRefs operation
|
|
@@ -719,7 +1106,12 @@ app.post('/youtube/transcript', async (req, res) => {
|
|
|
719
1106
|
|
|
720
1107
|
let result;
|
|
721
1108
|
if (hasYtDlp()) {
|
|
722
|
-
|
|
1109
|
+
try {
|
|
1110
|
+
result = await ytDlpTranscript(reqId, url, videoId, lang);
|
|
1111
|
+
} catch (ytErr) {
|
|
1112
|
+
log('warn', 'yt-dlp failed, falling back to browser', { reqId, error: ytErr.message });
|
|
1113
|
+
result = await browserTranscript(reqId, url, videoId, lang);
|
|
1114
|
+
}
|
|
723
1115
|
} else {
|
|
724
1116
|
result = await browserTranscript(reqId, url, videoId, lang);
|
|
725
1117
|
}
|
|
@@ -759,16 +1151,52 @@ async function browserTranscript(reqId, url, videoId, lang) {
|
|
|
759
1151
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: NAVIGATE_TIMEOUT_MS });
|
|
760
1152
|
await page.waitForTimeout(2000);
|
|
761
1153
|
|
|
1154
|
+
// Extract caption track URLs and metadata from ytInitialPlayerResponse
|
|
762
1155
|
const meta = await page.evaluate(() => {
|
|
763
1156
|
const r = window.ytInitialPlayerResponse || (typeof ytInitialPlayerResponse !== 'undefined' ? ytInitialPlayerResponse : null);
|
|
764
|
-
if (!r) return { title: '' };
|
|
1157
|
+
if (!r) return { title: '', tracks: [] };
|
|
765
1158
|
const tracks = r?.captions?.playerCaptionsTracklistRenderer?.captionTracks || [];
|
|
766
1159
|
return {
|
|
767
1160
|
title: r?.videoDetails?.title || '',
|
|
768
|
-
|
|
1161
|
+
tracks: tracks.map(t => ({ code: t.languageCode, name: t.name?.simpleText || t.languageCode, kind: t.kind || 'manual', url: t.baseUrl })),
|
|
769
1162
|
};
|
|
770
1163
|
});
|
|
771
1164
|
|
|
1165
|
+
log('info', 'youtube transcript: extracted caption tracks', { reqId, title: meta.title, trackCount: meta.tracks.length, tracks: meta.tracks.map(t => t.code) });
|
|
1166
|
+
|
|
1167
|
+
// Strategy A: Fetch caption track URL directly from ytInitialPlayerResponse
|
|
1168
|
+
// These URLs are freshly signed by YouTube and work immediately
|
|
1169
|
+
if (meta.tracks && meta.tracks.length > 0) {
|
|
1170
|
+
const track = meta.tracks.find(t => t.code === lang) || meta.tracks[0];
|
|
1171
|
+
if (track && track.url) {
|
|
1172
|
+
const captionUrl = track.url + (track.url.includes('?') ? '&' : '?') + 'fmt=json3';
|
|
1173
|
+
log('info', 'youtube transcript: fetching caption track', { reqId, lang: track.code, url: captionUrl.substring(0, 100) });
|
|
1174
|
+
try {
|
|
1175
|
+
const captionResp = await page.evaluate(async (fetchUrl) => {
|
|
1176
|
+
const resp = await fetch(fetchUrl);
|
|
1177
|
+
return resp.ok ? await resp.text() : null;
|
|
1178
|
+
}, captionUrl);
|
|
1179
|
+
if (captionResp && captionResp.length > 0) {
|
|
1180
|
+
let transcriptText = null;
|
|
1181
|
+
if (captionResp.trimStart().startsWith('{')) transcriptText = parseJson3(captionResp);
|
|
1182
|
+
else if (captionResp.includes('WEBVTT')) transcriptText = parseVtt(captionResp);
|
|
1183
|
+
else if (captionResp.includes('<text')) transcriptText = parseXml(captionResp);
|
|
1184
|
+
if (transcriptText && transcriptText.trim()) {
|
|
1185
|
+
return {
|
|
1186
|
+
status: 'ok', transcript: transcriptText,
|
|
1187
|
+
video_url: url, video_id: videoId, video_title: meta.title,
|
|
1188
|
+
language: track.code, total_words: transcriptText.split(/\s+/).length,
|
|
1189
|
+
available_languages: meta.tracks.map(t => ({ code: t.code, name: t.name, kind: t.kind })),
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
} catch (fetchErr) {
|
|
1194
|
+
log('warn', 'youtube transcript: caption track fetch failed', { reqId, error: fetchErr.message });
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// Strategy B: Play video and intercept timedtext network response
|
|
772
1200
|
await page.evaluate(() => {
|
|
773
1201
|
const v = document.querySelector('video');
|
|
774
1202
|
if (v) { v.muted = true; v.play().catch(() => {}); }
|
|
@@ -781,7 +1209,7 @@ async function browserTranscript(reqId, url, videoId, lang) {
|
|
|
781
1209
|
if (!interceptedCaptions) {
|
|
782
1210
|
return {
|
|
783
1211
|
status: 'error', code: 404,
|
|
784
|
-
message: 'No captions
|
|
1212
|
+
message: 'No captions available for this video',
|
|
785
1213
|
video_url: url, video_id: videoId, title: meta.title,
|
|
786
1214
|
};
|
|
787
1215
|
}
|
|
@@ -828,6 +1256,11 @@ app.get('/health', (req, res) => {
|
|
|
828
1256
|
});
|
|
829
1257
|
});
|
|
830
1258
|
|
|
1259
|
+
app.get('/metrics', async (_req, res) => {
|
|
1260
|
+
res.set('Content-Type', metricsRegister.contentType);
|
|
1261
|
+
res.send(await metricsRegister.metrics());
|
|
1262
|
+
});
|
|
1263
|
+
|
|
831
1264
|
// Create new tab
|
|
832
1265
|
app.post('/tabs', async (req, res) => {
|
|
833
1266
|
try {
|
|
@@ -838,33 +1271,43 @@ app.post('/tabs', async (req, res) => {
|
|
|
838
1271
|
return res.status(400).json({ error: 'userId and sessionKey required' });
|
|
839
1272
|
}
|
|
840
1273
|
|
|
841
|
-
const
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
const
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
1274
|
+
const result = await withTimeout((async () => {
|
|
1275
|
+
const session = await getSession(userId);
|
|
1276
|
+
|
|
1277
|
+
let totalTabs = 0;
|
|
1278
|
+
for (const group of session.tabGroups.values()) totalTabs += group.size;
|
|
1279
|
+
if (totalTabs >= MAX_TABS_PER_SESSION) {
|
|
1280
|
+
throw Object.assign(new Error('Maximum tabs per session reached'), { statusCode: 429 });
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
if (getTotalTabCount() >= MAX_TABS_GLOBAL) {
|
|
1284
|
+
throw Object.assign(new Error('Maximum global tabs reached'), { statusCode: 429 });
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
const group = getTabGroup(session, resolvedSessionKey);
|
|
1288
|
+
|
|
1289
|
+
const page = await session.context.newPage();
|
|
1290
|
+
const tabId = crypto.randomUUID();
|
|
1291
|
+
const tabState = createTabState(page);
|
|
1292
|
+
attachDownloadListener(tabState, tabId);
|
|
1293
|
+
group.set(tabId, tabState);
|
|
1294
|
+
refreshActiveTabsGauge();
|
|
1295
|
+
|
|
1296
|
+
if (url) {
|
|
1297
|
+
const urlErr = validateUrl(url);
|
|
1298
|
+
if (urlErr) throw Object.assign(new Error(urlErr), { statusCode: 400 });
|
|
1299
|
+
await withPageLoadDuration('open_url', () => page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }));
|
|
1300
|
+
tabState.visitedUrls.add(url);
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
log('info', 'tab created', { reqId: req.reqId, tabId, userId, sessionKey: resolvedSessionKey, url: page.url() });
|
|
1304
|
+
return { tabId, url: page.url() };
|
|
1305
|
+
})(), HANDLER_TIMEOUT_MS, 'tab create');
|
|
1306
|
+
|
|
1307
|
+
res.json(result);
|
|
865
1308
|
} catch (err) {
|
|
866
1309
|
log('error', 'tab create failed', { reqId: req.reqId, error: err.message });
|
|
867
|
-
|
|
1310
|
+
handleRouteError(err, req, res);
|
|
868
1311
|
}
|
|
869
1312
|
});
|
|
870
1313
|
|
|
@@ -906,7 +1349,7 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
|
|
|
906
1349
|
const group = getTabGroup(session, resolvedSessionKey);
|
|
907
1350
|
if (oldestGroup) oldestGroup.delete(oldestTabId);
|
|
908
1351
|
group.set(tabId, tabState);
|
|
909
|
-
tabLocks.delete(oldestTabId);
|
|
1352
|
+
{ const _l = tabLocks.get(oldestTabId); if (_l) _l.drain(); tabLocks.delete(oldestTabId); }
|
|
910
1353
|
log('info', 'tab recycled (limit reached)', { reqId: req.reqId, tabId, recycledFrom: oldestTabId, userId });
|
|
911
1354
|
} else {
|
|
912
1355
|
throw new Error('Maximum tabs per session reached');
|
|
@@ -914,17 +1357,19 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
|
|
|
914
1357
|
} else {
|
|
915
1358
|
const page = await session.context.newPage();
|
|
916
1359
|
tabState = createTabState(page);
|
|
1360
|
+
attachDownloadListener(tabState, tabId, log);
|
|
917
1361
|
const group = getTabGroup(session, resolvedSessionKey);
|
|
918
1362
|
group.set(tabId, tabState);
|
|
1363
|
+
refreshActiveTabsGauge();
|
|
919
1364
|
log('info', 'tab auto-created on navigate', { reqId: req.reqId, tabId, userId });
|
|
920
1365
|
}
|
|
921
1366
|
} else {
|
|
922
1367
|
tabState = found.tabState;
|
|
923
1368
|
}
|
|
924
|
-
tabState.toolCalls++;
|
|
1369
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
925
1370
|
|
|
926
1371
|
let targetUrl = url;
|
|
927
|
-
if (macro) {
|
|
1372
|
+
if (macro && macro !== '__NO__' && macro !== 'none' && macro !== 'null') {
|
|
928
1373
|
targetUrl = expandMacro(macro, query) || url;
|
|
929
1374
|
}
|
|
930
1375
|
|
|
@@ -934,9 +1379,18 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
|
|
|
934
1379
|
if (urlErr) throw new Error(urlErr);
|
|
935
1380
|
|
|
936
1381
|
return await withTabLock(tabId, async () => {
|
|
937
|
-
await tabState.page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
1382
|
+
await withPageLoadDuration('navigate', () => tabState.page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }));
|
|
938
1383
|
tabState.visitedUrls.add(targetUrl);
|
|
939
1384
|
tabState.lastSnapshot = null;
|
|
1385
|
+
|
|
1386
|
+
// For Google SERP: skip eager ref building during navigate.
|
|
1387
|
+
// Results render asynchronously after DOMContentLoaded — the snapshot
|
|
1388
|
+
// call will wait for and extract them.
|
|
1389
|
+
if (isGoogleSerp(tabState.page.url())) {
|
|
1390
|
+
tabState.refs = new Map();
|
|
1391
|
+
return { ok: true, tabId, url: tabState.page.url(), refsAvailable: false, googleSerp: true };
|
|
1392
|
+
}
|
|
1393
|
+
|
|
940
1394
|
tabState.refs = await buildRefs(tabState.page);
|
|
941
1395
|
return { ok: true, tabId, url: tabState.page.url(), refsAvailable: tabState.refs.size > 0 };
|
|
942
1396
|
});
|
|
@@ -946,8 +1400,11 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
|
|
|
946
1400
|
res.json(result);
|
|
947
1401
|
} catch (err) {
|
|
948
1402
|
log('error', 'navigate failed', { reqId: req.reqId, tabId, error: err.message });
|
|
949
|
-
const
|
|
950
|
-
|
|
1403
|
+
const is400 = err.message && (err.message.startsWith('Blocked URL scheme') || err.message === 'url or macro required');
|
|
1404
|
+
if (is400) {
|
|
1405
|
+
return res.status(400).json({ error: safeError(err) });
|
|
1406
|
+
}
|
|
1407
|
+
handleRouteError(err, req, res);
|
|
951
1408
|
}
|
|
952
1409
|
});
|
|
953
1410
|
|
|
@@ -963,7 +1420,7 @@ app.get('/tabs/:tabId/snapshot', async (req, res) => {
|
|
|
963
1420
|
if (!found) return res.status(404).json({ error: 'Tab not found' });
|
|
964
1421
|
|
|
965
1422
|
const { tabState } = found;
|
|
966
|
-
tabState.toolCalls++;
|
|
1423
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
967
1424
|
|
|
968
1425
|
// Cached chunk retrieval for offset>0 requests
|
|
969
1426
|
if (offset > 0 && tabState.lastSnapshot) {
|
|
@@ -978,6 +1435,31 @@ app.get('/tabs/:tabId/snapshot', async (req, res) => {
|
|
|
978
1435
|
}
|
|
979
1436
|
|
|
980
1437
|
const result = await withUserLimit(userId, () => withTimeout((async () => {
|
|
1438
|
+
const pageUrl = tabState.page.url();
|
|
1439
|
+
|
|
1440
|
+
// Google SERP fast path — DOM extraction instead of ariaSnapshot
|
|
1441
|
+
if (isGoogleSerp(pageUrl)) {
|
|
1442
|
+
const { refs: googleRefs, snapshot: googleSnapshot } = await extractGoogleSerp(tabState.page);
|
|
1443
|
+
tabState.refs = googleRefs;
|
|
1444
|
+
tabState.lastSnapshot = googleSnapshot;
|
|
1445
|
+
const annotatedYaml = googleSnapshot;
|
|
1446
|
+
const win = windowSnapshot(annotatedYaml, 0);
|
|
1447
|
+
const response = {
|
|
1448
|
+
url: pageUrl,
|
|
1449
|
+
snapshot: win.text,
|
|
1450
|
+
refsCount: tabState.refs.size,
|
|
1451
|
+
truncated: win.truncated,
|
|
1452
|
+
totalChars: win.totalChars,
|
|
1453
|
+
hasMore: win.hasMore,
|
|
1454
|
+
nextOffset: win.nextOffset,
|
|
1455
|
+
};
|
|
1456
|
+
if (req.query.includeScreenshot === 'true') {
|
|
1457
|
+
const pngBuffer = await tabState.page.screenshot({ type: 'png' });
|
|
1458
|
+
response.screenshot = { data: pngBuffer.toString('base64'), mimeType: 'image/png' };
|
|
1459
|
+
}
|
|
1460
|
+
return response;
|
|
1461
|
+
}
|
|
1462
|
+
|
|
981
1463
|
tabState.refs = await buildRefs(tabState.page);
|
|
982
1464
|
const ariaYaml = await getAriaSnapshot(tabState.page);
|
|
983
1465
|
|
|
@@ -1040,7 +1522,7 @@ app.get('/tabs/:tabId/snapshot', async (req, res) => {
|
|
|
1040
1522
|
res.json(result);
|
|
1041
1523
|
} catch (err) {
|
|
1042
1524
|
log('error', 'snapshot failed', { reqId: req.reqId, tabId: req.params.tabId, error: err.message });
|
|
1043
|
-
|
|
1525
|
+
handleRouteError(err, req, res);
|
|
1044
1526
|
}
|
|
1045
1527
|
});
|
|
1046
1528
|
|
|
@@ -1058,7 +1540,7 @@ app.post('/tabs/:tabId/wait', async (req, res) => {
|
|
|
1058
1540
|
res.json({ ok: true, ready });
|
|
1059
1541
|
} catch (err) {
|
|
1060
1542
|
log('error', 'wait failed', { reqId: req.reqId, error: err.message });
|
|
1061
|
-
|
|
1543
|
+
handleRouteError(err, req, res);
|
|
1062
1544
|
}
|
|
1063
1545
|
});
|
|
1064
1546
|
|
|
@@ -1074,13 +1556,15 @@ app.post('/tabs/:tabId/click', async (req, res) => {
|
|
|
1074
1556
|
if (!found) return res.status(404).json({ error: 'Tab not found' });
|
|
1075
1557
|
|
|
1076
1558
|
const { tabState } = found;
|
|
1077
|
-
tabState.toolCalls++;
|
|
1559
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
1078
1560
|
|
|
1079
1561
|
if (!ref && !selector) {
|
|
1080
1562
|
return res.status(400).json({ error: 'ref or selector required' });
|
|
1081
1563
|
}
|
|
1082
1564
|
|
|
1083
|
-
const result = await withUserLimit(userId, () =>
|
|
1565
|
+
const result = await withUserLimit(userId, () => withTabLock(tabId, async () => {
|
|
1566
|
+
const clickStart = Date.now();
|
|
1567
|
+
const remainingBudget = () => Math.max(0, HANDLER_TIMEOUT_MS - 2000 - (Date.now() - clickStart));
|
|
1084
1568
|
// Full mouse event sequence for stubborn JS click handlers (mirrors Swift WebView.swift)
|
|
1085
1569
|
// Dispatches: mouseover → mouseenter → mousedown → mouseup → click
|
|
1086
1570
|
const dispatchMouseSequence = async (locator) => {
|
|
@@ -1102,18 +1586,32 @@ app.post('/tabs/:tabId/click', async (req, res) => {
|
|
|
1102
1586
|
log('info', 'mouse sequence dispatched', { x: x.toFixed(0), y: y.toFixed(0) });
|
|
1103
1587
|
};
|
|
1104
1588
|
|
|
1589
|
+
// On Google SERPs, skip the normal click attempt (always intercepted by overlays)
|
|
1590
|
+
// and go directly to force click — saves 5s timeout per click
|
|
1591
|
+
const onGoogleSerp = isGoogleSerp(tabState.page.url());
|
|
1592
|
+
|
|
1105
1593
|
const doClick = async (locatorOrSelector, isLocator) => {
|
|
1106
1594
|
const locator = isLocator ? locatorOrSelector : tabState.page.locator(locatorOrSelector);
|
|
1107
1595
|
|
|
1596
|
+
if (onGoogleSerp) {
|
|
1597
|
+
try {
|
|
1598
|
+
await locator.click({ timeout: 3000, force: true });
|
|
1599
|
+
} catch (forceErr) {
|
|
1600
|
+
log('warn', 'google force click failed, trying mouse sequence');
|
|
1601
|
+
await dispatchMouseSequence(locator);
|
|
1602
|
+
}
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1108
1606
|
try {
|
|
1109
1607
|
// First try normal click (respects visibility, enabled, not-obscured)
|
|
1110
|
-
await locator.click({ timeout:
|
|
1608
|
+
await locator.click({ timeout: 3000 });
|
|
1111
1609
|
} catch (err) {
|
|
1112
1610
|
// Fallback 1: If intercepted by overlay, retry with force
|
|
1113
1611
|
if (err.message.includes('intercepts pointer events')) {
|
|
1114
1612
|
log('warn', 'click intercepted, retrying with force');
|
|
1115
1613
|
try {
|
|
1116
|
-
await locator.click({ timeout:
|
|
1614
|
+
await locator.click({ timeout: 3000, force: true });
|
|
1117
1615
|
} catch (forceErr) {
|
|
1118
1616
|
// Fallback 2: Full mouse event sequence for stubborn JS handlers
|
|
1119
1617
|
log('warn', 'force click failed, trying mouse sequence');
|
|
@@ -1131,35 +1629,93 @@ app.post('/tabs/:tabId/click', async (req, res) => {
|
|
|
1131
1629
|
|
|
1132
1630
|
if (ref) {
|
|
1133
1631
|
let locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
1134
|
-
if (!locator
|
|
1135
|
-
//
|
|
1136
|
-
log('info', 'auto-refreshing
|
|
1137
|
-
|
|
1632
|
+
if (!locator) {
|
|
1633
|
+
// Use tight timeout (4s max) to leave budget for click + post-click buildRefs
|
|
1634
|
+
log('info', 'auto-refreshing refs before click', { ref, hadRefs: tabState.refs.size });
|
|
1635
|
+
try {
|
|
1636
|
+
const preClickBudget = Math.min(4000, remainingBudget());
|
|
1637
|
+
const refreshPromise = buildRefs(tabState.page);
|
|
1638
|
+
const refreshBudget = new Promise((_, reject) => setTimeout(() => reject(new Error('pre_click_refs_timeout')), preClickBudget));
|
|
1639
|
+
tabState.refs = await Promise.race([refreshPromise, refreshBudget]);
|
|
1640
|
+
} catch (e) {
|
|
1641
|
+
if (e.message === 'pre_click_refs_timeout' || e.message === 'buildRefs_timeout') {
|
|
1642
|
+
log('warn', 'pre-click buildRefs timed out, proceeding without refresh');
|
|
1643
|
+
} else {
|
|
1644
|
+
throw e;
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1138
1647
|
locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
1139
1648
|
}
|
|
1140
1649
|
if (!locator) {
|
|
1141
1650
|
const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none';
|
|
1142
|
-
throw new
|
|
1651
|
+
throw new StaleRefsError(ref, maxRef, tabState.refs.size);
|
|
1143
1652
|
}
|
|
1144
1653
|
await doClick(locator, true);
|
|
1145
1654
|
} else {
|
|
1146
1655
|
await doClick(selector, false);
|
|
1147
1656
|
}
|
|
1148
1657
|
|
|
1149
|
-
|
|
1658
|
+
// If clicking on a Google SERP, wait for potential navigation to complete
|
|
1659
|
+
if (onGoogleSerp) {
|
|
1660
|
+
try {
|
|
1661
|
+
await tabState.page.waitForLoadState('domcontentloaded', { timeout: 3000 });
|
|
1662
|
+
} catch {}
|
|
1663
|
+
await tabState.page.waitForTimeout(200);
|
|
1664
|
+
// Skip buildRefs here — SERP clicks typically navigate to a new page,
|
|
1665
|
+
// and the caller always requests /snapshot next which rebuilds refs.
|
|
1666
|
+
tabState.lastSnapshot = null;
|
|
1667
|
+
tabState.refs = new Map();
|
|
1668
|
+
const newUrl = tabState.page.url();
|
|
1669
|
+
tabState.visitedUrls.add(newUrl);
|
|
1670
|
+
return { ok: true, url: newUrl, refsAvailable: false };
|
|
1671
|
+
} else {
|
|
1672
|
+
await tabState.page.waitForTimeout(500);
|
|
1673
|
+
}
|
|
1150
1674
|
tabState.lastSnapshot = null;
|
|
1151
|
-
|
|
1675
|
+
// buildRefs after click — use remaining budget (min 2s) so we don't blow the handler timeout.
|
|
1676
|
+
// If it times out, return without refs (caller's next /snapshot will rebuild them).
|
|
1677
|
+
const postClickBudget = Math.max(2000, remainingBudget());
|
|
1678
|
+
try {
|
|
1679
|
+
const refsPromise = buildRefs(tabState.page);
|
|
1680
|
+
const refsBudget = new Promise((_, reject) => setTimeout(() => reject(new Error('post_click_refs_timeout')), postClickBudget));
|
|
1681
|
+
tabState.refs = await Promise.race([refsPromise, refsBudget]);
|
|
1682
|
+
} catch (e) {
|
|
1683
|
+
if (e.message === 'post_click_refs_timeout' || e.message === 'buildRefs_timeout') {
|
|
1684
|
+
log('warn', 'post-click buildRefs timed out, returning without refs', { budget: postClickBudget, elapsed: Date.now() - clickStart });
|
|
1685
|
+
tabState.refs = new Map();
|
|
1686
|
+
} else {
|
|
1687
|
+
throw e;
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1152
1690
|
|
|
1153
1691
|
const newUrl = tabState.page.url();
|
|
1154
1692
|
tabState.visitedUrls.add(newUrl);
|
|
1155
1693
|
return { ok: true, url: newUrl, refsAvailable: tabState.refs.size > 0 };
|
|
1156
|
-
})
|
|
1694
|
+
}));
|
|
1157
1695
|
|
|
1158
1696
|
log('info', 'clicked', { reqId: req.reqId, tabId, url: result.url });
|
|
1159
1697
|
res.json(result);
|
|
1160
1698
|
} catch (err) {
|
|
1161
1699
|
log('error', 'click failed', { reqId: req.reqId, tabId, error: err.message });
|
|
1162
|
-
|
|
1700
|
+
if (err.message?.includes('timed out')) {
|
|
1701
|
+
try {
|
|
1702
|
+
const session = sessions.get(normalizeUserId(req.body.userId));
|
|
1703
|
+
const found = session && findTab(session, tabId);
|
|
1704
|
+
if (found?.tabState?.page && !found.tabState.page.isClosed()) {
|
|
1705
|
+
found.tabState.refs = await buildRefs(found.tabState.page);
|
|
1706
|
+
found.tabState.lastSnapshot = null;
|
|
1707
|
+
return res.status(500).json({
|
|
1708
|
+
error: safeError(err),
|
|
1709
|
+
hint: 'The page may have changed. Call snapshot to see the current state and retry.',
|
|
1710
|
+
url: found.tabState.page.url(),
|
|
1711
|
+
refsCount: found.tabState.refs.size,
|
|
1712
|
+
});
|
|
1713
|
+
}
|
|
1714
|
+
} catch (refreshErr) {
|
|
1715
|
+
log('warn', 'post-timeout refresh failed', { error: refreshErr.message });
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
handleRouteError(err, req, res);
|
|
1163
1719
|
}
|
|
1164
1720
|
});
|
|
1165
1721
|
|
|
@@ -1174,7 +1730,7 @@ app.post('/tabs/:tabId/type', async (req, res) => {
|
|
|
1174
1730
|
if (!found) return res.status(404).json({ error: 'Tab not found' });
|
|
1175
1731
|
|
|
1176
1732
|
const { tabState } = found;
|
|
1177
|
-
tabState.toolCalls++;
|
|
1733
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
1178
1734
|
|
|
1179
1735
|
if (!ref && !selector) {
|
|
1180
1736
|
return res.status(400).json({ error: 'ref or selector required' });
|
|
@@ -1182,8 +1738,13 @@ app.post('/tabs/:tabId/type', async (req, res) => {
|
|
|
1182
1738
|
|
|
1183
1739
|
await withTabLock(tabId, async () => {
|
|
1184
1740
|
if (ref) {
|
|
1185
|
-
|
|
1186
|
-
if (!locator)
|
|
1741
|
+
let locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
1742
|
+
if (!locator) {
|
|
1743
|
+
log('info', 'auto-refreshing refs before fill', { ref, hadRefs: tabState.refs.size });
|
|
1744
|
+
tabState.refs = await buildRefs(tabState.page);
|
|
1745
|
+
locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
1746
|
+
}
|
|
1747
|
+
if (!locator) { const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none'; throw new StaleRefsError(ref, maxRef, tabState.refs.size); }
|
|
1187
1748
|
await locator.fill(text, { timeout: 10000 });
|
|
1188
1749
|
} else {
|
|
1189
1750
|
await tabState.page.fill(selector, text, { timeout: 10000 });
|
|
@@ -1193,7 +1754,25 @@ app.post('/tabs/:tabId/type', async (req, res) => {
|
|
|
1193
1754
|
res.json({ ok: true });
|
|
1194
1755
|
} catch (err) {
|
|
1195
1756
|
log('error', 'type failed', { reqId: req.reqId, error: err.message });
|
|
1196
|
-
|
|
1757
|
+
if (err.message?.includes('timed out') || err.message?.includes('not an <input>')) {
|
|
1758
|
+
try {
|
|
1759
|
+
const session = sessions.get(normalizeUserId(req.body.userId));
|
|
1760
|
+
const found = session && findTab(session, tabId);
|
|
1761
|
+
if (found?.tabState?.page && !found.tabState.page.isClosed()) {
|
|
1762
|
+
found.tabState.refs = await buildRefs(found.tabState.page);
|
|
1763
|
+
found.tabState.lastSnapshot = null;
|
|
1764
|
+
return res.status(500).json({
|
|
1765
|
+
error: safeError(err),
|
|
1766
|
+
hint: 'The page may have changed. Call snapshot to see the current state and retry.',
|
|
1767
|
+
url: found.tabState.page.url(),
|
|
1768
|
+
refsCount: found.tabState.refs.size,
|
|
1769
|
+
});
|
|
1770
|
+
}
|
|
1771
|
+
} catch (refreshErr) {
|
|
1772
|
+
log('warn', 'post-timeout refresh failed', { error: refreshErr.message });
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
handleRouteError(err, req, res);
|
|
1197
1776
|
}
|
|
1198
1777
|
});
|
|
1199
1778
|
|
|
@@ -1208,7 +1787,7 @@ app.post('/tabs/:tabId/press', async (req, res) => {
|
|
|
1208
1787
|
if (!found) return res.status(404).json({ error: 'Tab not found' });
|
|
1209
1788
|
|
|
1210
1789
|
const { tabState } = found;
|
|
1211
|
-
tabState.toolCalls++;
|
|
1790
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
1212
1791
|
|
|
1213
1792
|
await withTabLock(tabId, async () => {
|
|
1214
1793
|
await tabState.page.keyboard.press(key);
|
|
@@ -1217,7 +1796,7 @@ app.post('/tabs/:tabId/press', async (req, res) => {
|
|
|
1217
1796
|
res.json({ ok: true });
|
|
1218
1797
|
} catch (err) {
|
|
1219
1798
|
log('error', 'press failed', { reqId: req.reqId, error: err.message });
|
|
1220
|
-
|
|
1799
|
+
handleRouteError(err, req, res);
|
|
1221
1800
|
}
|
|
1222
1801
|
});
|
|
1223
1802
|
|
|
@@ -1230,7 +1809,7 @@ app.post('/tabs/:tabId/scroll', async (req, res) => {
|
|
|
1230
1809
|
if (!found) return res.status(404).json({ error: 'Tab not found' });
|
|
1231
1810
|
|
|
1232
1811
|
const { tabState } = found;
|
|
1233
|
-
tabState.toolCalls++;
|
|
1812
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
1234
1813
|
|
|
1235
1814
|
const delta = direction === 'up' ? -amount : amount;
|
|
1236
1815
|
await tabState.page.mouse.wheel(0, delta);
|
|
@@ -1239,7 +1818,7 @@ app.post('/tabs/:tabId/scroll', async (req, res) => {
|
|
|
1239
1818
|
res.json({ ok: true });
|
|
1240
1819
|
} catch (err) {
|
|
1241
1820
|
log('error', 'scroll failed', { reqId: req.reqId, error: err.message });
|
|
1242
|
-
|
|
1821
|
+
handleRouteError(err, req, res);
|
|
1243
1822
|
}
|
|
1244
1823
|
});
|
|
1245
1824
|
|
|
@@ -1254,18 +1833,28 @@ app.post('/tabs/:tabId/back', async (req, res) => {
|
|
|
1254
1833
|
if (!found) return res.status(404).json({ error: 'Tab not found' });
|
|
1255
1834
|
|
|
1256
1835
|
const { tabState } = found;
|
|
1257
|
-
tabState.toolCalls++;
|
|
1258
|
-
|
|
1259
|
-
const result = await
|
|
1260
|
-
|
|
1836
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
1837
|
+
|
|
1838
|
+
const result = await withTabLock(tabId, async () => {
|
|
1839
|
+
try {
|
|
1840
|
+
await tabState.page.goBack({ timeout: 10000 });
|
|
1841
|
+
} catch (navErr) {
|
|
1842
|
+
// NS_BINDING_CANCELLED_OLD_LOAD: Firefox cancels the old load when going back.
|
|
1843
|
+
// The navigation itself succeeded — just the prior page's load was interrupted.
|
|
1844
|
+
if (navErr.message && navErr.message.includes('NS_BINDING_CANCELLED')) {
|
|
1845
|
+
log('info', 'goBack cancelled old load (expected)', { reqId: req.reqId, tabId });
|
|
1846
|
+
} else {
|
|
1847
|
+
throw navErr;
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1261
1850
|
tabState.refs = await buildRefs(tabState.page);
|
|
1262
1851
|
return { ok: true, url: tabState.page.url() };
|
|
1263
|
-
})
|
|
1852
|
+
});
|
|
1264
1853
|
|
|
1265
1854
|
res.json(result);
|
|
1266
1855
|
} catch (err) {
|
|
1267
1856
|
log('error', 'back failed', { reqId: req.reqId, error: err.message });
|
|
1268
|
-
|
|
1857
|
+
handleRouteError(err, req, res);
|
|
1269
1858
|
}
|
|
1270
1859
|
});
|
|
1271
1860
|
|
|
@@ -1280,18 +1869,18 @@ app.post('/tabs/:tabId/forward', async (req, res) => {
|
|
|
1280
1869
|
if (!found) return res.status(404).json({ error: 'Tab not found' });
|
|
1281
1870
|
|
|
1282
1871
|
const { tabState } = found;
|
|
1283
|
-
tabState.toolCalls++;
|
|
1872
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
1284
1873
|
|
|
1285
|
-
const result = await
|
|
1874
|
+
const result = await withTabLock(tabId, async () => {
|
|
1286
1875
|
await tabState.page.goForward({ timeout: 10000 });
|
|
1287
1876
|
tabState.refs = await buildRefs(tabState.page);
|
|
1288
1877
|
return { ok: true, url: tabState.page.url() };
|
|
1289
|
-
})
|
|
1878
|
+
});
|
|
1290
1879
|
|
|
1291
1880
|
res.json(result);
|
|
1292
1881
|
} catch (err) {
|
|
1293
1882
|
log('error', 'forward failed', { reqId: req.reqId, error: err.message });
|
|
1294
|
-
|
|
1883
|
+
handleRouteError(err, req, res);
|
|
1295
1884
|
}
|
|
1296
1885
|
});
|
|
1297
1886
|
|
|
@@ -1306,18 +1895,18 @@ app.post('/tabs/:tabId/refresh', async (req, res) => {
|
|
|
1306
1895
|
if (!found) return res.status(404).json({ error: 'Tab not found' });
|
|
1307
1896
|
|
|
1308
1897
|
const { tabState } = found;
|
|
1309
|
-
tabState.toolCalls++;
|
|
1898
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
1310
1899
|
|
|
1311
|
-
const result = await
|
|
1900
|
+
const result = await withTabLock(tabId, async () => {
|
|
1312
1901
|
await tabState.page.reload({ timeout: 30000 });
|
|
1313
1902
|
tabState.refs = await buildRefs(tabState.page);
|
|
1314
1903
|
return { ok: true, url: tabState.page.url() };
|
|
1315
|
-
})
|
|
1904
|
+
});
|
|
1316
1905
|
|
|
1317
1906
|
res.json(result);
|
|
1318
1907
|
} catch (err) {
|
|
1319
1908
|
log('error', 'refresh failed', { reqId: req.reqId, error: err.message });
|
|
1320
|
-
|
|
1909
|
+
handleRouteError(err, req, res);
|
|
1321
1910
|
}
|
|
1322
1911
|
});
|
|
1323
1912
|
|
|
@@ -1335,7 +1924,7 @@ app.get('/tabs/:tabId/links', async (req, res) => {
|
|
|
1335
1924
|
}
|
|
1336
1925
|
|
|
1337
1926
|
const { tabState } = found;
|
|
1338
|
-
tabState.toolCalls++;
|
|
1927
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
1339
1928
|
|
|
1340
1929
|
const allLinks = await tabState.page.evaluate(() => {
|
|
1341
1930
|
const links = [];
|
|
@@ -1358,6 +1947,59 @@ app.get('/tabs/:tabId/links', async (req, res) => {
|
|
|
1358
1947
|
});
|
|
1359
1948
|
} catch (err) {
|
|
1360
1949
|
log('error', 'links failed', { reqId: req.reqId, error: err.message });
|
|
1950
|
+
handleRouteError(err, req, res);
|
|
1951
|
+
}
|
|
1952
|
+
});
|
|
1953
|
+
|
|
1954
|
+
// Get captured downloads
|
|
1955
|
+
app.get('/tabs/:tabId/downloads', async (req, res) => {
|
|
1956
|
+
try {
|
|
1957
|
+
const userId = req.query.userId;
|
|
1958
|
+
const includeData = req.query.includeData === 'true';
|
|
1959
|
+
const consume = req.query.consume === 'true';
|
|
1960
|
+
const maxBytesRaw = Number(req.query.maxBytes);
|
|
1961
|
+
const maxBytes = Number.isFinite(maxBytesRaw) && maxBytesRaw > 0 ? maxBytesRaw : MAX_DOWNLOAD_INLINE_BYTES;
|
|
1962
|
+
const session = sessions.get(normalizeUserId(userId));
|
|
1963
|
+
const found = session && findTab(session, req.params.tabId);
|
|
1964
|
+
if (!found) return res.status(404).json({ error: 'Tab not found' });
|
|
1965
|
+
|
|
1966
|
+
const { tabState } = found;
|
|
1967
|
+
tabState.toolCalls++;
|
|
1968
|
+
|
|
1969
|
+
const downloads = await getDownloadsList(tabState, { includeData, maxBytes });
|
|
1970
|
+
|
|
1971
|
+
if (consume) {
|
|
1972
|
+
await clearTabDownloads(tabState);
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
res.json({ tabId: req.params.tabId, downloads });
|
|
1976
|
+
} catch (err) {
|
|
1977
|
+
log('error', 'downloads failed', { reqId: req.reqId, error: err.message });
|
|
1978
|
+
res.status(500).json({ error: safeError(err) });
|
|
1979
|
+
}
|
|
1980
|
+
});
|
|
1981
|
+
|
|
1982
|
+
// Get image elements from current page
|
|
1983
|
+
app.get('/tabs/:tabId/images', async (req, res) => {
|
|
1984
|
+
try {
|
|
1985
|
+
const userId = req.query.userId;
|
|
1986
|
+
const includeData = req.query.includeData === 'true';
|
|
1987
|
+
const maxBytesRaw = Number(req.query.maxBytes);
|
|
1988
|
+
const limitRaw = Number(req.query.limit);
|
|
1989
|
+
const maxBytes = Number.isFinite(maxBytesRaw) && maxBytesRaw > 0 ? maxBytesRaw : MAX_DOWNLOAD_INLINE_BYTES;
|
|
1990
|
+
const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(Math.floor(limitRaw), 20) : 8;
|
|
1991
|
+
const session = sessions.get(normalizeUserId(userId));
|
|
1992
|
+
const found = session && findTab(session, req.params.tabId);
|
|
1993
|
+
if (!found) return res.status(404).json({ error: 'Tab not found' });
|
|
1994
|
+
|
|
1995
|
+
const { tabState } = found;
|
|
1996
|
+
tabState.toolCalls++;
|
|
1997
|
+
|
|
1998
|
+
const images = await extractPageImages(tabState.page, { includeData, maxBytes, limit });
|
|
1999
|
+
|
|
2000
|
+
res.json({ tabId: req.params.tabId, images });
|
|
2001
|
+
} catch (err) {
|
|
2002
|
+
log('error', 'images failed', { reqId: req.reqId, error: err.message });
|
|
1361
2003
|
res.status(500).json({ error: safeError(err) });
|
|
1362
2004
|
}
|
|
1363
2005
|
});
|
|
@@ -1377,7 +2019,7 @@ app.get('/tabs/:tabId/screenshot', async (req, res) => {
|
|
|
1377
2019
|
res.send(buffer);
|
|
1378
2020
|
} catch (err) {
|
|
1379
2021
|
log('error', 'screenshot failed', { reqId: req.reqId, error: err.message });
|
|
1380
|
-
|
|
2022
|
+
handleRouteError(err, req, res);
|
|
1381
2023
|
}
|
|
1382
2024
|
});
|
|
1383
2025
|
|
|
@@ -1396,11 +2038,36 @@ app.get('/tabs/:tabId/stats', async (req, res) => {
|
|
|
1396
2038
|
listItemId, // Legacy compatibility
|
|
1397
2039
|
url: tabState.page.url(),
|
|
1398
2040
|
visitedUrls: Array.from(tabState.visitedUrls),
|
|
2041
|
+
downloadsCount: Array.isArray(tabState.downloads) ? tabState.downloads.length : 0,
|
|
1399
2042
|
toolCalls: tabState.toolCalls,
|
|
1400
2043
|
refsCount: tabState.refs.size
|
|
1401
2044
|
});
|
|
1402
2045
|
} catch (err) {
|
|
1403
2046
|
log('error', 'stats failed', { reqId: req.reqId, error: err.message });
|
|
2047
|
+
handleRouteError(err, req, res);
|
|
2048
|
+
}
|
|
2049
|
+
});
|
|
2050
|
+
|
|
2051
|
+
// Evaluate JavaScript in page context
|
|
2052
|
+
app.post('/tabs/:tabId/evaluate', express.json({ limit: '1mb' }), async (req, res) => {
|
|
2053
|
+
try {
|
|
2054
|
+
const { userId, expression } = req.body;
|
|
2055
|
+
if (!userId) return res.status(400).json({ error: 'userId is required' });
|
|
2056
|
+
if (!expression) return res.status(400).json({ error: 'expression is required' });
|
|
2057
|
+
|
|
2058
|
+
const session = sessions.get(normalizeUserId(userId));
|
|
2059
|
+
const found = session && findTab(session, req.params.tabId);
|
|
2060
|
+
if (!found) return res.status(404).json({ error: 'Tab not found' });
|
|
2061
|
+
|
|
2062
|
+
session.lastAccess = Date.now();
|
|
2063
|
+
const { tabState } = found;
|
|
2064
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
2065
|
+
|
|
2066
|
+
const result = await tabState.page.evaluate(expression);
|
|
2067
|
+
log('info', 'evaluate', { reqId: req.reqId, tabId: req.params.tabId, userId, resultType: typeof result });
|
|
2068
|
+
res.json({ ok: true, result });
|
|
2069
|
+
} catch (err) {
|
|
2070
|
+
log('error', 'evaluate failed', { reqId: req.reqId, error: err.message });
|
|
1404
2071
|
res.status(500).json({ error: safeError(err) });
|
|
1405
2072
|
}
|
|
1406
2073
|
});
|
|
@@ -1412,18 +2079,20 @@ app.delete('/tabs/:tabId', async (req, res) => {
|
|
|
1412
2079
|
const session = sessions.get(normalizeUserId(userId));
|
|
1413
2080
|
const found = session && findTab(session, req.params.tabId);
|
|
1414
2081
|
if (found) {
|
|
2082
|
+
await clearTabDownloads(found.tabState);
|
|
1415
2083
|
await safePageClose(found.tabState.page);
|
|
1416
2084
|
found.group.delete(req.params.tabId);
|
|
1417
|
-
tabLocks.delete(req.params.tabId);
|
|
2085
|
+
{ const _l = tabLocks.get(req.params.tabId); if (_l) _l.drain(); tabLocks.delete(req.params.tabId); refreshTabLockQueueDepth(); }
|
|
1418
2086
|
if (found.group.size === 0) {
|
|
1419
2087
|
session.tabGroups.delete(found.listItemId);
|
|
1420
2088
|
}
|
|
2089
|
+
refreshActiveTabsGauge();
|
|
1421
2090
|
log('info', 'tab closed', { reqId: req.reqId, tabId: req.params.tabId, userId });
|
|
1422
2091
|
}
|
|
1423
2092
|
res.json({ ok: true });
|
|
1424
2093
|
} catch (err) {
|
|
1425
2094
|
log('error', 'tab close failed', { reqId: req.reqId, error: err.message });
|
|
1426
|
-
|
|
2095
|
+
handleRouteError(err, req, res);
|
|
1427
2096
|
}
|
|
1428
2097
|
});
|
|
1429
2098
|
|
|
@@ -1435,16 +2104,23 @@ app.delete('/tabs/group/:listItemId', async (req, res) => {
|
|
|
1435
2104
|
const group = session?.tabGroups.get(req.params.listItemId);
|
|
1436
2105
|
if (group) {
|
|
1437
2106
|
for (const [tabId, tabState] of group) {
|
|
2107
|
+
await clearTabDownloads(tabState);
|
|
1438
2108
|
await safePageClose(tabState.page);
|
|
1439
|
-
tabLocks.
|
|
2109
|
+
const lock = tabLocks.get(tabId);
|
|
2110
|
+
if (lock) {
|
|
2111
|
+
lock.drain();
|
|
2112
|
+
tabLocks.delete(tabId);
|
|
2113
|
+
}
|
|
1440
2114
|
}
|
|
1441
2115
|
session.tabGroups.delete(req.params.listItemId);
|
|
2116
|
+
refreshTabLockQueueDepth();
|
|
2117
|
+
refreshActiveTabsGauge();
|
|
1442
2118
|
log('info', 'tab group closed', { reqId: req.reqId, listItemId: req.params.listItemId, userId });
|
|
1443
2119
|
}
|
|
1444
2120
|
res.json({ ok: true });
|
|
1445
2121
|
} catch (err) {
|
|
1446
2122
|
log('error', 'tab group close failed', { reqId: req.reqId, error: err.message });
|
|
1447
|
-
|
|
2123
|
+
handleRouteError(err, req, res);
|
|
1448
2124
|
}
|
|
1449
2125
|
});
|
|
1450
2126
|
|
|
@@ -1454,15 +2130,28 @@ app.delete('/sessions/:userId', async (req, res) => {
|
|
|
1454
2130
|
const userId = normalizeUserId(req.params.userId);
|
|
1455
2131
|
const session = sessions.get(userId);
|
|
1456
2132
|
if (session) {
|
|
2133
|
+
await clearSessionDownloads(session);
|
|
1457
2134
|
await session.context.close();
|
|
1458
2135
|
sessions.delete(userId);
|
|
2136
|
+
// Remove any lingering tab locks for the session
|
|
2137
|
+
for (const [listItemId, group] of session.tabGroups) {
|
|
2138
|
+
for (const tabId of group.keys()) {
|
|
2139
|
+
const lock = tabLocks.get(tabId);
|
|
2140
|
+
if (lock) {
|
|
2141
|
+
lock.drain();
|
|
2142
|
+
tabLocks.delete(tabId);
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
refreshTabLockQueueDepth();
|
|
2147
|
+
refreshActiveTabsGauge();
|
|
1459
2148
|
log('info', 'session closed', { userId });
|
|
1460
2149
|
}
|
|
1461
2150
|
if (sessions.size === 0) scheduleBrowserIdleShutdown();
|
|
1462
2151
|
res.json({ ok: true });
|
|
1463
2152
|
} catch (err) {
|
|
1464
2153
|
log('error', 'session close failed', { error: err.message });
|
|
1465
|
-
|
|
2154
|
+
handleRouteError(err, req, res);
|
|
1466
2155
|
}
|
|
1467
2156
|
});
|
|
1468
2157
|
|
|
@@ -1471,8 +2160,10 @@ setInterval(() => {
|
|
|
1471
2160
|
const now = Date.now();
|
|
1472
2161
|
for (const [userId, session] of sessions) {
|
|
1473
2162
|
if (now - session.lastAccess > SESSION_TIMEOUT_MS) {
|
|
2163
|
+
clearSessionDownloads(session).catch(() => {});
|
|
1474
2164
|
session.context.close().catch(() => {});
|
|
1475
2165
|
sessions.delete(userId);
|
|
2166
|
+
refreshActiveTabsGauge();
|
|
1476
2167
|
log('info', 'session expired', { userId });
|
|
1477
2168
|
}
|
|
1478
2169
|
}
|
|
@@ -1480,6 +2171,40 @@ setInterval(() => {
|
|
|
1480
2171
|
if (sessions.size === 0) {
|
|
1481
2172
|
scheduleBrowserIdleShutdown();
|
|
1482
2173
|
}
|
|
2174
|
+
refreshTabLockQueueDepth();
|
|
2175
|
+
}, 60_000);
|
|
2176
|
+
|
|
2177
|
+
// Per-tab inactivity reaper — close tabs idle for TAB_INACTIVITY_MS
|
|
2178
|
+
setInterval(() => {
|
|
2179
|
+
const now = Date.now();
|
|
2180
|
+
for (const [userId, session] of sessions) {
|
|
2181
|
+
for (const [listItemId, group] of session.tabGroups) {
|
|
2182
|
+
for (const [tabId, tabState] of group) {
|
|
2183
|
+
if (!tabState._lastReaperCheck) {
|
|
2184
|
+
tabState._lastReaperCheck = now;
|
|
2185
|
+
tabState._lastReaperToolCalls = tabState.toolCalls;
|
|
2186
|
+
continue;
|
|
2187
|
+
}
|
|
2188
|
+
if (tabState.toolCalls === tabState._lastReaperToolCalls) {
|
|
2189
|
+
const idleMs = now - tabState._lastReaperCheck;
|
|
2190
|
+
if (idleMs >= TAB_INACTIVITY_MS) {
|
|
2191
|
+
log('info', 'tab reaped (inactive)', { userId, tabId, listItemId, idleMs, toolCalls: tabState.toolCalls });
|
|
2192
|
+
safePageClose(tabState.page);
|
|
2193
|
+
group.delete(tabId);
|
|
2194
|
+
{ const _l = tabLocks.get(tabId); if (_l) _l.drain(); tabLocks.delete(tabId); }
|
|
2195
|
+
refreshTabLockQueueDepth();
|
|
2196
|
+
refreshActiveTabsGauge();
|
|
2197
|
+
}
|
|
2198
|
+
} else {
|
|
2199
|
+
tabState._lastReaperCheck = now;
|
|
2200
|
+
tabState._lastReaperToolCalls = tabState.toolCalls;
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
if (group.size === 0) {
|
|
2204
|
+
session.tabGroups.delete(listItemId);
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
1483
2208
|
}, 60_000);
|
|
1484
2209
|
|
|
1485
2210
|
// =============================================================================
|
|
@@ -1526,7 +2251,7 @@ app.get('/tabs', async (req, res) => {
|
|
|
1526
2251
|
res.json({ running: true, tabs });
|
|
1527
2252
|
} catch (err) {
|
|
1528
2253
|
log('error', 'list tabs failed', { reqId: req.reqId, error: err.message });
|
|
1529
|
-
|
|
2254
|
+
handleRouteError(err, req, res);
|
|
1530
2255
|
}
|
|
1531
2256
|
});
|
|
1532
2257
|
|
|
@@ -1546,6 +2271,11 @@ app.post('/tabs/open', async (req, res) => {
|
|
|
1546
2271
|
|
|
1547
2272
|
const session = await getSession(userId);
|
|
1548
2273
|
|
|
2274
|
+
// Check global tab limit first
|
|
2275
|
+
if (getTotalTabCount() >= MAX_TABS_GLOBAL) {
|
|
2276
|
+
return res.status(429).json({ error: 'Maximum global tabs reached' });
|
|
2277
|
+
}
|
|
2278
|
+
|
|
1549
2279
|
let totalTabs = 0;
|
|
1550
2280
|
for (const g of session.tabGroups.values()) totalTabs += g.size;
|
|
1551
2281
|
if (totalTabs >= MAX_TABS_PER_SESSION) {
|
|
@@ -1557,9 +2287,11 @@ app.post('/tabs/open', async (req, res) => {
|
|
|
1557
2287
|
const page = await session.context.newPage();
|
|
1558
2288
|
const tabId = crypto.randomUUID();
|
|
1559
2289
|
const tabState = createTabState(page);
|
|
2290
|
+
attachDownloadListener(tabState, tabId, log);
|
|
1560
2291
|
group.set(tabId, tabState);
|
|
2292
|
+
refreshActiveTabsGauge();
|
|
1561
2293
|
|
|
1562
|
-
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
2294
|
+
await withPageLoadDuration('open_url', () => page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }));
|
|
1563
2295
|
tabState.visitedUrls.add(url);
|
|
1564
2296
|
|
|
1565
2297
|
log('info', 'openclaw tab opened', { reqId: req.reqId, tabId, url: page.url() });
|
|
@@ -1572,7 +2304,7 @@ app.post('/tabs/open', async (req, res) => {
|
|
|
1572
2304
|
});
|
|
1573
2305
|
} catch (err) {
|
|
1574
2306
|
log('error', 'openclaw tab open failed', { reqId: req.reqId, error: err.message });
|
|
1575
|
-
|
|
2307
|
+
handleRouteError(err, req, res);
|
|
1576
2308
|
}
|
|
1577
2309
|
});
|
|
1578
2310
|
|
|
@@ -1597,7 +2329,26 @@ app.post('/stop', async (req, res) => {
|
|
|
1597
2329
|
await browser.close().catch(() => {});
|
|
1598
2330
|
browser = null;
|
|
1599
2331
|
}
|
|
2332
|
+
const cleanupTasks = [];
|
|
2333
|
+
for (const session of sessions.values()) {
|
|
2334
|
+
cleanupTasks.push(clearSessionDownloads(session));
|
|
2335
|
+
}
|
|
2336
|
+
await Promise.all(cleanupTasks);
|
|
2337
|
+
for (const session of sessions.values()) {
|
|
2338
|
+
for (const [, group] of session.tabGroups) {
|
|
2339
|
+
for (const tabId of group.keys()) {
|
|
2340
|
+
const lock = tabLocks.get(tabId);
|
|
2341
|
+
if (lock) {
|
|
2342
|
+
lock.drain();
|
|
2343
|
+
tabLocks.delete(tabId);
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
tabLocks.clear();
|
|
1600
2349
|
sessions.clear();
|
|
2350
|
+
refreshActiveTabsGauge();
|
|
2351
|
+
refreshTabLockQueueDepth();
|
|
1601
2352
|
res.json({ ok: true, stopped: true, profile: 'camoufox' });
|
|
1602
2353
|
} catch (err) {
|
|
1603
2354
|
res.status(500).json({ ok: false, error: safeError(err) });
|
|
@@ -1625,19 +2376,27 @@ app.post('/navigate', async (req, res) => {
|
|
|
1625
2376
|
}
|
|
1626
2377
|
|
|
1627
2378
|
const { tabState } = found;
|
|
1628
|
-
tabState.toolCalls++;
|
|
2379
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
1629
2380
|
|
|
1630
|
-
const result = await
|
|
1631
|
-
await tabState.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
2381
|
+
const result = await withTabLock(targetId, async () => {
|
|
2382
|
+
await withPageLoadDuration('navigate', () => tabState.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }));
|
|
1632
2383
|
tabState.visitedUrls.add(url);
|
|
2384
|
+
tabState.lastSnapshot = null;
|
|
2385
|
+
|
|
2386
|
+
// Google SERP: defer extraction to snapshot call
|
|
2387
|
+
if (isGoogleSerp(tabState.page.url())) {
|
|
2388
|
+
tabState.refs = new Map();
|
|
2389
|
+
return { ok: true, targetId, url: tabState.page.url(), googleSerp: true };
|
|
2390
|
+
}
|
|
2391
|
+
|
|
1633
2392
|
tabState.refs = await buildRefs(tabState.page);
|
|
1634
2393
|
return { ok: true, targetId, url: tabState.page.url() };
|
|
1635
|
-
})
|
|
2394
|
+
});
|
|
1636
2395
|
|
|
1637
2396
|
res.json(result);
|
|
1638
2397
|
} catch (err) {
|
|
1639
2398
|
log('error', 'openclaw navigate failed', { reqId: req.reqId, error: err.message });
|
|
1640
|
-
|
|
2399
|
+
handleRouteError(err, req, res);
|
|
1641
2400
|
}
|
|
1642
2401
|
});
|
|
1643
2402
|
|
|
@@ -1657,7 +2416,7 @@ app.get('/snapshot', async (req, res) => {
|
|
|
1657
2416
|
}
|
|
1658
2417
|
|
|
1659
2418
|
const { tabState } = found;
|
|
1660
|
-
tabState.toolCalls++;
|
|
2419
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
1661
2420
|
|
|
1662
2421
|
// Cached chunk retrieval
|
|
1663
2422
|
if (offset > 0 && tabState.lastSnapshot) {
|
|
@@ -1670,6 +2429,28 @@ app.get('/snapshot', async (req, res) => {
|
|
|
1670
2429
|
return res.json(response);
|
|
1671
2430
|
}
|
|
1672
2431
|
|
|
2432
|
+
const pageUrl = tabState.page.url();
|
|
2433
|
+
|
|
2434
|
+
// Google SERP fast path
|
|
2435
|
+
if (isGoogleSerp(pageUrl)) {
|
|
2436
|
+
const { refs: googleRefs, snapshot: googleSnapshot } = await extractGoogleSerp(tabState.page);
|
|
2437
|
+
tabState.refs = googleRefs;
|
|
2438
|
+
tabState.lastSnapshot = googleSnapshot;
|
|
2439
|
+
const annotatedYaml = googleSnapshot;
|
|
2440
|
+
const win = windowSnapshot(annotatedYaml, 0);
|
|
2441
|
+
const response = {
|
|
2442
|
+
ok: true, format: 'aria', targetId, url: pageUrl,
|
|
2443
|
+
snapshot: win.text, refsCount: tabState.refs.size,
|
|
2444
|
+
truncated: win.truncated, totalChars: win.totalChars,
|
|
2445
|
+
hasMore: win.hasMore, nextOffset: win.nextOffset,
|
|
2446
|
+
};
|
|
2447
|
+
if (req.query.includeScreenshot === 'true') {
|
|
2448
|
+
const pngBuffer = await tabState.page.screenshot({ type: 'png' });
|
|
2449
|
+
response.screenshot = { data: pngBuffer.toString('base64'), mimeType: 'image/png' };
|
|
2450
|
+
}
|
|
2451
|
+
return res.json(response);
|
|
2452
|
+
}
|
|
2453
|
+
|
|
1673
2454
|
tabState.refs = await buildRefs(tabState.page);
|
|
1674
2455
|
|
|
1675
2456
|
const ariaYaml = await getAriaSnapshot(tabState.page);
|
|
@@ -1722,7 +2503,7 @@ app.get('/snapshot', async (req, res) => {
|
|
|
1722
2503
|
res.json(response);
|
|
1723
2504
|
} catch (err) {
|
|
1724
2505
|
log('error', 'openclaw snapshot failed', { reqId: req.reqId, error: err.message });
|
|
1725
|
-
|
|
2506
|
+
handleRouteError(err, req, res);
|
|
1726
2507
|
}
|
|
1727
2508
|
});
|
|
1728
2509
|
|
|
@@ -1746,9 +2527,9 @@ app.post('/act', async (req, res) => {
|
|
|
1746
2527
|
}
|
|
1747
2528
|
|
|
1748
2529
|
const { tabState } = found;
|
|
1749
|
-
tabState.toolCalls++;
|
|
2530
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
1750
2531
|
|
|
1751
|
-
const result = await
|
|
2532
|
+
const result = await withTabLock(targetId, async () => {
|
|
1752
2533
|
switch (kind) {
|
|
1753
2534
|
case 'click': {
|
|
1754
2535
|
const { ref, selector, doubleClick } = params;
|
|
@@ -1758,7 +2539,7 @@ app.post('/act', async (req, res) => {
|
|
|
1758
2539
|
|
|
1759
2540
|
const doClick = async (locatorOrSelector, isLocator) => {
|
|
1760
2541
|
const locator = isLocator ? locatorOrSelector : tabState.page.locator(locatorOrSelector);
|
|
1761
|
-
const clickOpts = { timeout:
|
|
2542
|
+
const clickOpts = { timeout: 3000 };
|
|
1762
2543
|
if (doubleClick) clickOpts.clickCount = 2;
|
|
1763
2544
|
|
|
1764
2545
|
try {
|
|
@@ -1773,8 +2554,13 @@ app.post('/act', async (req, res) => {
|
|
|
1773
2554
|
};
|
|
1774
2555
|
|
|
1775
2556
|
if (ref) {
|
|
1776
|
-
|
|
1777
|
-
if (!locator)
|
|
2557
|
+
let locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
2558
|
+
if (!locator) {
|
|
2559
|
+
log('info', 'auto-refreshing refs before click (openclaw)', { ref, hadRefs: tabState.refs.size });
|
|
2560
|
+
tabState.refs = await buildRefs(tabState.page);
|
|
2561
|
+
locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
2562
|
+
}
|
|
2563
|
+
if (!locator) { const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none'; throw new StaleRefsError(ref, maxRef, tabState.refs.size); }
|
|
1778
2564
|
await doClick(locator, true);
|
|
1779
2565
|
} else {
|
|
1780
2566
|
await doClick(selector, false);
|
|
@@ -1795,8 +2581,13 @@ app.post('/act', async (req, res) => {
|
|
|
1795
2581
|
}
|
|
1796
2582
|
|
|
1797
2583
|
if (ref) {
|
|
1798
|
-
|
|
1799
|
-
if (!locator)
|
|
2584
|
+
let locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
2585
|
+
if (!locator) {
|
|
2586
|
+
log('info', 'auto-refreshing refs before type (openclaw)', { ref, hadRefs: tabState.refs.size });
|
|
2587
|
+
tabState.refs = await buildRefs(tabState.page);
|
|
2588
|
+
locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
2589
|
+
}
|
|
2590
|
+
if (!locator) { const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none'; throw new StaleRefsError(ref, maxRef, tabState.refs.size); }
|
|
1800
2591
|
await locator.fill(text, { timeout: 10000 });
|
|
1801
2592
|
if (submit) await tabState.page.keyboard.press('Enter');
|
|
1802
2593
|
} else {
|
|
@@ -1817,8 +2608,12 @@ app.post('/act', async (req, res) => {
|
|
|
1817
2608
|
case 'scrollIntoView': {
|
|
1818
2609
|
const { ref, direction = 'down', amount = 500 } = params;
|
|
1819
2610
|
if (ref) {
|
|
1820
|
-
|
|
1821
|
-
if (!locator)
|
|
2611
|
+
let locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
2612
|
+
if (!locator) {
|
|
2613
|
+
tabState.refs = await buildRefs(tabState.page);
|
|
2614
|
+
locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
2615
|
+
}
|
|
2616
|
+
if (!locator) { const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none'; throw new StaleRefsError(ref, maxRef, tabState.refs.size); }
|
|
1822
2617
|
await locator.scrollIntoViewIfNeeded({ timeout: 5000 });
|
|
1823
2618
|
} else {
|
|
1824
2619
|
const delta = direction === 'up' ? -amount : amount;
|
|
@@ -1833,8 +2628,12 @@ app.post('/act', async (req, res) => {
|
|
|
1833
2628
|
if (!ref && !selector) throw new Error('ref or selector required');
|
|
1834
2629
|
|
|
1835
2630
|
if (ref) {
|
|
1836
|
-
|
|
1837
|
-
if (!locator)
|
|
2631
|
+
let locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
2632
|
+
if (!locator) {
|
|
2633
|
+
tabState.refs = await buildRefs(tabState.page);
|
|
2634
|
+
locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
2635
|
+
}
|
|
2636
|
+
if (!locator) { const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none'; throw new StaleRefsError(ref, maxRef, tabState.refs.size); }
|
|
1838
2637
|
await locator.hover({ timeout: 5000 });
|
|
1839
2638
|
} else {
|
|
1840
2639
|
await tabState.page.locator(selector).hover({ timeout: 5000 });
|
|
@@ -1857,19 +2656,19 @@ app.post('/act', async (req, res) => {
|
|
|
1857
2656
|
case 'close': {
|
|
1858
2657
|
await safePageClose(tabState.page);
|
|
1859
2658
|
found.group.delete(targetId);
|
|
1860
|
-
tabLocks.delete(targetId);
|
|
2659
|
+
{ const _l = tabLocks.get(targetId); if (_l) _l.drain(); tabLocks.delete(targetId); }
|
|
1861
2660
|
return { ok: true, targetId };
|
|
1862
2661
|
}
|
|
1863
2662
|
|
|
1864
2663
|
default:
|
|
1865
2664
|
throw new Error(`Unsupported action kind: ${kind}`);
|
|
1866
2665
|
}
|
|
1867
|
-
})
|
|
2666
|
+
});
|
|
1868
2667
|
|
|
1869
2668
|
res.json(result);
|
|
1870
2669
|
} catch (err) {
|
|
1871
2670
|
log('error', 'act failed', { reqId: req.reqId, kind: req.body?.kind, error: err.message });
|
|
1872
|
-
|
|
2671
|
+
handleRouteError(err, req, res);
|
|
1873
2672
|
}
|
|
1874
2673
|
});
|
|
1875
2674
|
|
|
@@ -1895,14 +2694,20 @@ setInterval(() => {
|
|
|
1895
2694
|
// Active health probe — detect hung browser even when isConnected() lies
|
|
1896
2695
|
setInterval(async () => {
|
|
1897
2696
|
if (!browser || healthState.isRecovering) return;
|
|
1898
|
-
|
|
1899
|
-
if
|
|
2697
|
+
const timeSinceSuccess = Date.now() - healthState.lastSuccessfulNav;
|
|
2698
|
+
// Skip probe if operations are in flight AND last success was recent.
|
|
2699
|
+
// If it's been >120s since any successful operation, probe anyway —
|
|
2700
|
+
// active ops are likely stuck on a frozen browser and will time out eventually.
|
|
2701
|
+
if (healthState.activeOps > 0 && timeSinceSuccess < 120000) {
|
|
1900
2702
|
log('info', 'health probe skipped, operations active', { activeOps: healthState.activeOps });
|
|
1901
2703
|
return;
|
|
1902
2704
|
}
|
|
1903
|
-
const timeSinceSuccess = Date.now() - healthState.lastSuccessfulNav;
|
|
1904
2705
|
if (timeSinceSuccess < 120000) return;
|
|
1905
2706
|
|
|
2707
|
+
if (healthState.activeOps > 0) {
|
|
2708
|
+
log('warn', 'health probe forced despite active ops', { activeOps: healthState.activeOps, timeSinceSuccessMs: timeSinceSuccess });
|
|
2709
|
+
}
|
|
2710
|
+
|
|
1906
2711
|
let testContext;
|
|
1907
2712
|
try {
|
|
1908
2713
|
testContext = await browser.newContext();
|
|
@@ -1942,6 +2747,7 @@ async function gracefulShutdown(signal) {
|
|
|
1942
2747
|
forceTimeout.unref();
|
|
1943
2748
|
|
|
1944
2749
|
server.close();
|
|
2750
|
+
stopMemoryReporter();
|
|
1945
2751
|
|
|
1946
2752
|
for (const [userId, session] of sessions) {
|
|
1947
2753
|
await session.context.close().catch(() => {});
|
|
@@ -1954,9 +2760,20 @@ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
|
1954
2760
|
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
1955
2761
|
|
|
1956
2762
|
const PORT = CONFIG.port;
|
|
1957
|
-
const server = app.listen(PORT, () => {
|
|
2763
|
+
const server = app.listen(PORT, async () => {
|
|
2764
|
+
startMemoryReporter();
|
|
2765
|
+
refreshActiveTabsGauge();
|
|
2766
|
+
refreshTabLockQueueDepth();
|
|
1958
2767
|
log('info', 'server started', { port: PORT, pid: process.pid, nodeVersion: process.version });
|
|
1959
|
-
//
|
|
2768
|
+
// Pre-warm browser so first request doesn't eat a 6-7s cold start
|
|
2769
|
+
try {
|
|
2770
|
+
const start = Date.now();
|
|
2771
|
+
await ensureBrowser();
|
|
2772
|
+
log('info', 'browser pre-warmed', { ms: Date.now() - start });
|
|
2773
|
+
scheduleBrowserIdleShutdown();
|
|
2774
|
+
} catch (err) {
|
|
2775
|
+
log('error', 'browser pre-warm failed (will retry on first request)', { error: err.message });
|
|
2776
|
+
}
|
|
1960
2777
|
});
|
|
1961
2778
|
|
|
1962
2779
|
server.on('error', (err) => {
|