@askjo/camofox-browser 1.4.0 → 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/lib/metrics.js ADDED
@@ -0,0 +1,99 @@
1
+ // Prometheus metrics for camofox-browser.
2
+ // Isolated in lib/ to keep process.env out of server.js (OpenClaw scanner rule).
3
+ import client from 'prom-client';
4
+
5
+ const register = new client.Registry();
6
+ client.collectDefaultMetrics({ register });
7
+
8
+ // --- Counters ---
9
+
10
+ export const requestsTotal = new client.Counter({
11
+ name: 'jo_browser_requests_total',
12
+ help: 'Total HTTP requests by action and status',
13
+ labelNames: ['action', 'status'],
14
+ registers: [register],
15
+ });
16
+
17
+ export const tabLockTimeoutsTotal = new client.Counter({
18
+ name: 'jo_browser_tab_lock_timeouts_total',
19
+ help: 'Tab lock queue timeouts resulting in 503',
20
+ registers: [register],
21
+ });
22
+
23
+ // --- Histograms ---
24
+
25
+ export const requestDuration = new client.Histogram({
26
+ name: 'jo_browser_request_duration_seconds',
27
+ help: 'Request duration in seconds by action',
28
+ labelNames: ['action'],
29
+ buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60],
30
+ registers: [register],
31
+ });
32
+
33
+ export const pageLoadDuration = new client.Histogram({
34
+ name: 'jo_browser_page_load_duration_seconds',
35
+ help: 'Page load duration in seconds',
36
+ buckets: [0.5, 1, 2, 5, 10, 20, 30, 60],
37
+ registers: [register],
38
+ });
39
+
40
+ // --- Gauges ---
41
+
42
+ export const activeTabsGauge = new client.Gauge({
43
+ name: 'jo_browser_active_tabs',
44
+ help: 'Current number of open browser tabs',
45
+ registers: [register],
46
+ });
47
+
48
+ export const tabLockQueueDepth = new client.Gauge({
49
+ name: 'jo_browser_tab_lock_queue_depth',
50
+ help: 'Current number of requests waiting for a tab lock',
51
+ registers: [register],
52
+ });
53
+
54
+ export const memoryUsageBytes = new client.Gauge({
55
+ name: 'jo_browser_memory_usage_bytes',
56
+ help: 'Process RSS memory usage in bytes',
57
+ registers: [register],
58
+ });
59
+
60
+ // Periodic memory reporter
61
+ const MEMORY_INTERVAL_MS = 30_000;
62
+ let memoryTimer = null;
63
+
64
+ export function startMemoryReporter() {
65
+ if (memoryTimer) return;
66
+ const report = () => memoryUsageBytes.set(process.memoryUsage().rss);
67
+ report();
68
+ memoryTimer = setInterval(report, MEMORY_INTERVAL_MS);
69
+ memoryTimer.unref(); // don't keep process alive
70
+ }
71
+
72
+ export function stopMemoryReporter() {
73
+ if (memoryTimer) { clearInterval(memoryTimer); memoryTimer = null; }
74
+ }
75
+
76
+ // Helper: derive a short action name from Express route
77
+ export function actionFromReq(req) {
78
+ const method = req.method;
79
+ const path = req.route?.path || req.path;
80
+ // POST /tabs -> create_tab, DELETE /tabs/:tabId -> delete_tab, etc.
81
+ if (path === '/tabs' && method === 'POST') return 'create_tab';
82
+ if (path === '/tabs/:tabId' && method === 'DELETE') return 'delete_tab';
83
+ if (path === '/tabs/group/:listItemId' && method === 'DELETE') return 'delete_tab_group';
84
+ if (path === '/sessions/:userId' && method === 'DELETE') return 'delete_session';
85
+ if (path === '/sessions/:userId/cookies' && method === 'POST') return 'set_cookies';
86
+ if (path === '/tabs/open' && method === 'POST') return 'open_url';
87
+ if (path === '/tabs' && method === 'GET') return 'list_tabs';
88
+ // /tabs/:tabId/<action>
89
+ const m = path.match(/^\/tabs\/:tabId\/(\w+)$/);
90
+ if (m) return m[1]; // navigate, snapshot, click, type, scroll, etc.
91
+ // legacy compat routes
92
+ if (['/start', '/stop', '/navigate', '/snapshot', '/act'].includes(path)) return path.slice(1);
93
+ if (path === '/youtube/transcript') return 'youtube_transcript';
94
+ if (path === '/health') return 'health';
95
+ if (path === '/metrics') return 'metrics';
96
+ return `${method.toLowerCase()}_${path.replace(/[/:]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '')}`;
97
+ }
98
+
99
+ export { register };
package/lib/proxy.js ADDED
@@ -0,0 +1,19 @@
1
+ function decodeProxyCredential(value) {
2
+ if (!value) return value;
3
+
4
+ try {
5
+ return decodeURIComponent(value);
6
+ } catch {
7
+ return value;
8
+ }
9
+ }
10
+
11
+ export function normalizePlaywrightProxy(proxy) {
12
+ if (!proxy) return proxy;
13
+
14
+ return {
15
+ ...proxy,
16
+ username: decodeProxyCredential(proxy.username),
17
+ password: decodeProxyCredential(proxy.password),
18
+ };
19
+ }
@@ -2,7 +2,7 @@
2
2
  "id": "camofox-browser",
3
3
  "name": "Camofox Browser",
4
4
  "description": "Anti-detection browser automation for AI agents using Camoufox (Firefox-based)",
5
- "version": "1.4.0",
5
+ "version": "1.4.1",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "properties": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askjo/camofox-browser",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "Headless browser automation server and OpenClaw plugin for AI agents - anti-detection, element refs, and session isolation",
5
5
  "type": "module",
6
6
  "main": "server.js",
@@ -66,6 +66,7 @@
66
66
  "playwright": "^1.50.0",
67
67
  "playwright-core": "^1.58.0",
68
68
  "playwright-extra": "^4.3.6",
69
+ "prom-client": "^15.1.3",
69
70
  "puppeteer-extra-plugin-stealth": "^2.11.2"
70
71
  },
71
72
  "devDependencies": {
package/plugin.ts CHANGED
@@ -449,7 +449,7 @@ export default function register(api: PluginApi) {
449
449
  const userId = ctx.agentId || fallbackUserId;
450
450
  const result = await fetchApi(baseUrl, `/tabs/${tabId}/evaluate`, {
451
451
  method: "POST",
452
- body: { userId, expression },
452
+ body: JSON.stringify({ userId, expression }),
453
453
  });
454
454
  return toToolResult(result);
455
455
  },
package/server.js CHANGED
@@ -5,6 +5,7 @@ import crypto from 'crypto';
5
5
  import os from 'os';
6
6
  import { expandMacro } from './lib/macros.js';
7
7
  import { loadConfig } from './lib/config.js';
8
+ import { normalizePlaywrightProxy } from './lib/proxy.js';
8
9
  import { windowSnapshot } from './lib/snapshot.js';
9
10
  import {
10
11
  MAX_DOWNLOAD_INLINE_BYTES,
@@ -15,6 +16,12 @@ import {
15
16
  extractPageImages,
16
17
  } from './lib/downloads.js';
17
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';
18
25
 
19
26
  const CONFIG = loadConfig();
20
27
 
@@ -37,20 +44,34 @@ function log(level, msg, fields = {}) {
37
44
  const app = express();
38
45
  app.use(express.json({ limit: '100kb' }));
39
46
 
40
- // Request logging middleware
47
+ // Request logging + metrics middleware
41
48
  app.use((req, res, next) => {
42
- if (req.path === '/health') return next();
43
49
  const reqId = crypto.randomUUID().slice(0, 8);
44
50
  req.reqId = reqId;
45
51
  req.startTime = Date.now();
52
+
46
53
  const userId = req.body?.userId || req.query?.userId || '-';
47
- log('info', 'req', { reqId, method: req.method, path: req.path, userId });
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
+
48
61
  const origEnd = res.end.bind(res);
49
62
  res.end = function (...args) {
50
63
  const ms = Date.now() - req.startTime;
51
- log('info', 'res', { reqId, status: res.statusCode, ms });
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
+
52
72
  return origEnd(...args);
53
73
  };
74
+
54
75
  next();
55
76
  });
56
77
 
@@ -243,9 +264,12 @@ class TabLock {
243
264
  entry.timer = setTimeout(() => {
244
265
  const idx = this.queue.indexOf(entry);
245
266
  if (idx !== -1) this.queue.splice(idx, 1);
267
+ tabLockTimeoutsTotal.inc();
268
+ refreshTabLockQueueDepth();
246
269
  reject(new Error('Tab lock queue timeout'));
247
270
  }, timeoutMs);
248
271
  this.queue.push(entry);
272
+ refreshTabLockQueueDepth();
249
273
  this._tryNext();
250
274
  });
251
275
  }
@@ -253,6 +277,7 @@ class TabLock {
253
277
  release() {
254
278
  this.active = false;
255
279
  this._tryNext();
280
+ refreshTabLockQueueDepth();
256
281
  }
257
282
 
258
283
  _tryNext() {
@@ -260,6 +285,7 @@ class TabLock {
260
285
  this.active = true;
261
286
  const entry = this.queue.shift();
262
287
  clearTimeout(entry.timer);
288
+ refreshTabLockQueueDepth();
263
289
  entry.resolve();
264
290
  }
265
291
 
@@ -270,6 +296,7 @@ class TabLock {
270
296
  entry.reject(new Error('Tab destroyed'));
271
297
  }
272
298
  this.queue = [];
299
+ refreshTabLockQueueDepth();
273
300
  }
274
301
  }
275
302
 
@@ -463,6 +490,7 @@ async function launchBrowserInstance() {
463
490
  proxy: proxy,
464
491
  geoip: !!proxy,
465
492
  });
493
+ options.proxy = normalizePlaywrightProxy(options.proxy);
466
494
 
467
495
  browser = await firefox.launch(options);
468
496
  log('info', 'camoufox launched');
@@ -612,6 +640,7 @@ function destroyTab(session, tabId) {
612
640
  if (lock) {
613
641
  lock.drain();
614
642
  tabLocks.delete(tabId);
643
+ refreshTabLockQueueDepth();
615
644
  }
616
645
  for (const [listItemId, group] of session.tabGroups) {
617
646
  if (group.has(tabId)) {
@@ -620,6 +649,7 @@ function destroyTab(session, tabId) {
620
649
  safePageClose(tabState.page);
621
650
  group.delete(tabId);
622
651
  if (group.size === 0) session.tabGroups.delete(listItemId);
652
+ refreshActiveTabsGauge();
623
653
  return true;
624
654
  }
625
655
  }
@@ -657,6 +687,27 @@ function createTabState(page) {
657
687
  };
658
688
  }
659
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
+
660
711
 
661
712
 
662
713
  async function waitForPageReady(page, options = {}) {
@@ -1205,6 +1256,11 @@ app.get('/health', (req, res) => {
1205
1256
  });
1206
1257
  });
1207
1258
 
1259
+ app.get('/metrics', async (_req, res) => {
1260
+ res.set('Content-Type', metricsRegister.contentType);
1261
+ res.send(await metricsRegister.metrics());
1262
+ });
1263
+
1208
1264
  // Create new tab
1209
1265
  app.post('/tabs', async (req, res) => {
1210
1266
  try {
@@ -1235,11 +1291,12 @@ app.post('/tabs', async (req, res) => {
1235
1291
  const tabState = createTabState(page);
1236
1292
  attachDownloadListener(tabState, tabId);
1237
1293
  group.set(tabId, tabState);
1294
+ refreshActiveTabsGauge();
1238
1295
 
1239
1296
  if (url) {
1240
1297
  const urlErr = validateUrl(url);
1241
1298
  if (urlErr) throw Object.assign(new Error(urlErr), { statusCode: 400 });
1242
- await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
1299
+ await withPageLoadDuration('open_url', () => page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }));
1243
1300
  tabState.visitedUrls.add(url);
1244
1301
  }
1245
1302
 
@@ -1303,6 +1360,7 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
1303
1360
  attachDownloadListener(tabState, tabId, log);
1304
1361
  const group = getTabGroup(session, resolvedSessionKey);
1305
1362
  group.set(tabId, tabState);
1363
+ refreshActiveTabsGauge();
1306
1364
  log('info', 'tab auto-created on navigate', { reqId: req.reqId, tabId, userId });
1307
1365
  }
1308
1366
  } else {
@@ -1311,7 +1369,7 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
1311
1369
  tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
1312
1370
 
1313
1371
  let targetUrl = url;
1314
- if (macro) {
1372
+ if (macro && macro !== '__NO__' && macro !== 'none' && macro !== 'null') {
1315
1373
  targetUrl = expandMacro(macro, query) || url;
1316
1374
  }
1317
1375
 
@@ -1321,7 +1379,7 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
1321
1379
  if (urlErr) throw new Error(urlErr);
1322
1380
 
1323
1381
  return await withTabLock(tabId, async () => {
1324
- await tabState.page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
1382
+ await withPageLoadDuration('navigate', () => tabState.page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }));
1325
1383
  tabState.visitedUrls.add(targetUrl);
1326
1384
  tabState.lastSnapshot = null;
1327
1385
 
@@ -1342,8 +1400,8 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
1342
1400
  res.json(result);
1343
1401
  } catch (err) {
1344
1402
  log('error', 'navigate failed', { reqId: req.reqId, tabId, error: err.message });
1345
- const status = err.message && err.message.startsWith('Blocked URL scheme') ? 400 : 500;
1346
- if (status === 400) {
1403
+ const is400 = err.message && (err.message.startsWith('Blocked URL scheme') || err.message === 'url or macro required');
1404
+ if (is400) {
1347
1405
  return res.status(400).json({ error: safeError(err) });
1348
1406
  }
1349
1407
  handleRouteError(err, req, res);
@@ -1778,7 +1836,17 @@ app.post('/tabs/:tabId/back', async (req, res) => {
1778
1836
  tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
1779
1837
 
1780
1838
  const result = await withTabLock(tabId, async () => {
1781
- await tabState.page.goBack({ timeout: 10000 });
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
+ }
1782
1850
  tabState.refs = await buildRefs(tabState.page);
1783
1851
  return { ok: true, url: tabState.page.url() };
1784
1852
  });
@@ -2014,10 +2082,11 @@ app.delete('/tabs/:tabId', async (req, res) => {
2014
2082
  await clearTabDownloads(found.tabState);
2015
2083
  await safePageClose(found.tabState.page);
2016
2084
  found.group.delete(req.params.tabId);
2017
- { const _l = tabLocks.get(req.params.tabId); if (_l) _l.drain(); tabLocks.delete(req.params.tabId); }
2085
+ { const _l = tabLocks.get(req.params.tabId); if (_l) _l.drain(); tabLocks.delete(req.params.tabId); refreshTabLockQueueDepth(); }
2018
2086
  if (found.group.size === 0) {
2019
2087
  session.tabGroups.delete(found.listItemId);
2020
2088
  }
2089
+ refreshActiveTabsGauge();
2021
2090
  log('info', 'tab closed', { reqId: req.reqId, tabId: req.params.tabId, userId });
2022
2091
  }
2023
2092
  res.json({ ok: true });
@@ -2037,9 +2106,15 @@ app.delete('/tabs/group/:listItemId', async (req, res) => {
2037
2106
  for (const [tabId, tabState] of group) {
2038
2107
  await clearTabDownloads(tabState);
2039
2108
  await safePageClose(tabState.page);
2040
- tabLocks.delete(tabId);
2109
+ const lock = tabLocks.get(tabId);
2110
+ if (lock) {
2111
+ lock.drain();
2112
+ tabLocks.delete(tabId);
2113
+ }
2041
2114
  }
2042
2115
  session.tabGroups.delete(req.params.listItemId);
2116
+ refreshTabLockQueueDepth();
2117
+ refreshActiveTabsGauge();
2043
2118
  log('info', 'tab group closed', { reqId: req.reqId, listItemId: req.params.listItemId, userId });
2044
2119
  }
2045
2120
  res.json({ ok: true });
@@ -2058,6 +2133,18 @@ app.delete('/sessions/:userId', async (req, res) => {
2058
2133
  await clearSessionDownloads(session);
2059
2134
  await session.context.close();
2060
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();
2061
2148
  log('info', 'session closed', { userId });
2062
2149
  }
2063
2150
  if (sessions.size === 0) scheduleBrowserIdleShutdown();
@@ -2076,6 +2163,7 @@ setInterval(() => {
2076
2163
  clearSessionDownloads(session).catch(() => {});
2077
2164
  session.context.close().catch(() => {});
2078
2165
  sessions.delete(userId);
2166
+ refreshActiveTabsGauge();
2079
2167
  log('info', 'session expired', { userId });
2080
2168
  }
2081
2169
  }
@@ -2083,6 +2171,7 @@ setInterval(() => {
2083
2171
  if (sessions.size === 0) {
2084
2172
  scheduleBrowserIdleShutdown();
2085
2173
  }
2174
+ refreshTabLockQueueDepth();
2086
2175
  }, 60_000);
2087
2176
 
2088
2177
  // Per-tab inactivity reaper — close tabs idle for TAB_INACTIVITY_MS
@@ -2103,6 +2192,8 @@ setInterval(() => {
2103
2192
  safePageClose(tabState.page);
2104
2193
  group.delete(tabId);
2105
2194
  { const _l = tabLocks.get(tabId); if (_l) _l.drain(); tabLocks.delete(tabId); }
2195
+ refreshTabLockQueueDepth();
2196
+ refreshActiveTabsGauge();
2106
2197
  }
2107
2198
  } else {
2108
2199
  tabState._lastReaperCheck = now;
@@ -2198,8 +2289,9 @@ app.post('/tabs/open', async (req, res) => {
2198
2289
  const tabState = createTabState(page);
2199
2290
  attachDownloadListener(tabState, tabId, log);
2200
2291
  group.set(tabId, tabState);
2292
+ refreshActiveTabsGauge();
2201
2293
 
2202
- await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
2294
+ await withPageLoadDuration('open_url', () => page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }));
2203
2295
  tabState.visitedUrls.add(url);
2204
2296
 
2205
2297
  log('info', 'openclaw tab opened', { reqId: req.reqId, tabId, url: page.url() });
@@ -2242,7 +2334,21 @@ app.post('/stop', async (req, res) => {
2242
2334
  cleanupTasks.push(clearSessionDownloads(session));
2243
2335
  }
2244
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();
2245
2349
  sessions.clear();
2350
+ refreshActiveTabsGauge();
2351
+ refreshTabLockQueueDepth();
2246
2352
  res.json({ ok: true, stopped: true, profile: 'camoufox' });
2247
2353
  } catch (err) {
2248
2354
  res.status(500).json({ ok: false, error: safeError(err) });
@@ -2273,7 +2379,7 @@ app.post('/navigate', async (req, res) => {
2273
2379
  tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
2274
2380
 
2275
2381
  const result = await withTabLock(targetId, async () => {
2276
- await tabState.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
2382
+ await withPageLoadDuration('navigate', () => tabState.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }));
2277
2383
  tabState.visitedUrls.add(url);
2278
2384
  tabState.lastSnapshot = null;
2279
2385
 
@@ -2641,6 +2747,7 @@ async function gracefulShutdown(signal) {
2641
2747
  forceTimeout.unref();
2642
2748
 
2643
2749
  server.close();
2750
+ stopMemoryReporter();
2644
2751
 
2645
2752
  for (const [userId, session] of sessions) {
2646
2753
  await session.context.close().catch(() => {});
@@ -2654,12 +2761,16 @@ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
2654
2761
 
2655
2762
  const PORT = CONFIG.port;
2656
2763
  const server = app.listen(PORT, async () => {
2764
+ startMemoryReporter();
2765
+ refreshActiveTabsGauge();
2766
+ refreshTabLockQueueDepth();
2657
2767
  log('info', 'server started', { port: PORT, pid: process.pid, nodeVersion: process.version });
2658
2768
  // Pre-warm browser so first request doesn't eat a 6-7s cold start
2659
2769
  try {
2660
2770
  const start = Date.now();
2661
2771
  await ensureBrowser();
2662
2772
  log('info', 'browser pre-warmed', { ms: Date.now() - start });
2773
+ scheduleBrowserIdleShutdown();
2663
2774
  } catch (err) {
2664
2775
  log('error', 'browser pre-warm failed (will retry on first request)', { error: err.message });
2665
2776
  }