neoagent 3.2.1-beta.1 → 3.2.1-beta.10

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.
Files changed (172) hide show
  1. package/extensions/chrome-browser/background.mjs +318 -88
  2. package/extensions/chrome-browser/http.mjs +136 -0
  3. package/extensions/chrome-browser/protocol.mjs +654 -90
  4. package/flutter_app/lib/main_chat.dart +118 -739
  5. package/flutter_app/lib/main_controller.dart +111 -20
  6. package/flutter_app/lib/main_integrations.dart +607 -8
  7. package/flutter_app/lib/main_models.dart +3 -0
  8. package/flutter_app/lib/main_operations.dart +334 -321
  9. package/flutter_app/lib/main_security.dart +266 -112
  10. package/flutter_app/lib/main_settings.dart +4 -3
  11. package/flutter_app/lib/main_shared.dart +14 -11
  12. package/flutter_app/lib/src/backend_client.dart +78 -0
  13. package/flutter_app/lib/src/desktop_companion_actions.dart +185 -31
  14. package/flutter_app/lib/src/desktop_companion_io.dart +319 -86
  15. package/flutter_app/windows/runner/flutter_window.cpp +143 -32
  16. package/landing/index.html +3 -1
  17. package/lib/manager.js +106 -89
  18. package/lib/schema_migrations.js +115 -13
  19. package/package.json +30 -15
  20. package/runtime/paths.js +49 -5
  21. package/server/db/database.js +2 -2
  22. package/server/guest-agent.cli.package.json +13 -0
  23. package/server/guest_agent.js +85 -40
  24. package/server/http/middleware.js +24 -0
  25. package/server/http/routes.js +11 -6
  26. package/server/public/.last_build_id +1 -1
  27. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  28. package/server/public/flutter_bootstrap.js +2 -2
  29. package/server/public/main.dart.js +73083 -72209
  30. package/server/routes/admin.js +1 -1
  31. package/server/routes/android.js +30 -34
  32. package/server/routes/browser.js +23 -15
  33. package/server/routes/desktop.js +18 -1
  34. package/server/routes/integrations.js +107 -1
  35. package/server/routes/memory.js +1 -0
  36. package/server/routes/settings.js +16 -5
  37. package/server/routes/social_reach.js +12 -3
  38. package/server/routes/social_video.js +4 -0
  39. package/server/services/agents/manager.js +1 -1
  40. package/server/services/ai/capabilityHealth.js +62 -96
  41. package/server/services/ai/compaction.js +7 -2
  42. package/server/services/ai/history.js +45 -6
  43. package/server/services/ai/integrated_tools/http_request.js +8 -0
  44. package/server/services/ai/loop/agent_engine_core.js +390 -162
  45. package/server/services/ai/loop/blank_recovery.js +5 -4
  46. package/server/services/ai/loop/callbacks.js +1 -0
  47. package/server/services/ai/loop/completion_judge.js +121 -5
  48. package/server/services/ai/loop/conversation_loop.js +510 -332
  49. package/server/services/ai/loop/messaging_delivery.js +129 -57
  50. package/server/services/ai/loop/model_call_guard.js +91 -0
  51. package/server/services/ai/loop/model_io.js +20 -45
  52. package/server/services/ai/loop/progress_classification.js +2 -0
  53. package/server/services/ai/loop/tool_dispatch.js +19 -8
  54. package/server/services/ai/loopPolicy.js +48 -21
  55. package/server/services/ai/messagingFallback.js +17 -17
  56. package/server/services/ai/model_discovery.js +227 -0
  57. package/server/services/ai/model_failure_cache.js +108 -0
  58. package/server/services/ai/model_identity.js +71 -0
  59. package/server/services/ai/models.js +68 -163
  60. package/server/services/ai/providerRetry.js +17 -59
  61. package/server/services/ai/provider_selector.js +166 -0
  62. package/server/services/ai/providers/anthropic.js +2 -2
  63. package/server/services/ai/providers/claudeCode.js +21 -33
  64. package/server/services/ai/providers/githubCopilot.js +41 -20
  65. package/server/services/ai/providers/google.js +135 -97
  66. package/server/services/ai/providers/grok.js +4 -3
  67. package/server/services/ai/providers/grokOauth.js +19 -27
  68. package/server/services/ai/providers/nvidia.js +10 -5
  69. package/server/services/ai/providers/ollama.js +111 -84
  70. package/server/services/ai/providers/ollama_stream.js +142 -0
  71. package/server/services/ai/providers/openai.js +39 -5
  72. package/server/services/ai/providers/openaiCodex.js +11 -4
  73. package/server/services/ai/providers/openrouter.js +29 -7
  74. package/server/services/ai/providers/provider_error.js +36 -0
  75. package/server/services/ai/settings.js +26 -2
  76. package/server/services/ai/systemPrompt.js +19 -12
  77. package/server/services/ai/taskAnalysis.js +58 -10
  78. package/server/services/ai/terminal_reply.js +18 -0
  79. package/server/services/ai/toolEvidence.js +350 -29
  80. package/server/services/ai/tools.js +190 -111
  81. package/server/services/android/controller.js +770 -237
  82. package/server/services/android/process.js +140 -0
  83. package/server/services/android/sdk_download.js +143 -0
  84. package/server/services/android/uia.js +6 -5
  85. package/server/services/artifacts/store.js +24 -0
  86. package/server/services/browser/controller.js +843 -385
  87. package/server/services/browser/extension/gateway.js +40 -16
  88. package/server/services/browser/extension/protocol.js +15 -1
  89. package/server/services/browser/extension/provider.js +71 -47
  90. package/server/services/browser/extension/registry.js +155 -34
  91. package/server/services/cli/executor.js +62 -9
  92. package/server/services/credentials/bitwarden_cli.js +322 -0
  93. package/server/services/credentials/broker.js +594 -0
  94. package/server/services/desktop/gateway.js +41 -4
  95. package/server/services/desktop/protocol.js +3 -0
  96. package/server/services/desktop/provider.js +39 -42
  97. package/server/services/desktop/registry.js +137 -52
  98. package/server/services/integrations/bitwarden/constants.js +14 -0
  99. package/server/services/integrations/bitwarden/provider.js +197 -0
  100. package/server/services/integrations/bitwarden/snapshot.js +65 -0
  101. package/server/services/integrations/figma/provider.js +78 -12
  102. package/server/services/integrations/github/common.js +11 -6
  103. package/server/services/integrations/github/provider.js +52 -53
  104. package/server/services/integrations/google/provider.js +55 -19
  105. package/server/services/integrations/home_assistant/network.js +17 -20
  106. package/server/services/integrations/home_assistant/provider.js +7 -5
  107. package/server/services/integrations/home_assistant/tools.js +17 -5
  108. package/server/services/integrations/http.js +51 -0
  109. package/server/services/integrations/manager.js +159 -53
  110. package/server/services/integrations/microsoft/provider.js +80 -13
  111. package/server/services/integrations/neoarchive/provider.js +55 -29
  112. package/server/services/integrations/neorecall/client.js +17 -10
  113. package/server/services/integrations/neorecall/provider.js +20 -11
  114. package/server/services/integrations/notion/provider.js +16 -13
  115. package/server/services/integrations/oauth_provider.js +115 -51
  116. package/server/services/integrations/registry.js +2 -0
  117. package/server/services/integrations/slack/provider.js +98 -9
  118. package/server/services/integrations/spotify/provider.js +67 -71
  119. package/server/services/integrations/trello/provider.js +21 -7
  120. package/server/services/integrations/weather/provider.js +18 -12
  121. package/server/services/integrations/whatsapp/provider.js +76 -16
  122. package/server/services/manager.js +110 -1
  123. package/server/services/memory/embedding_index.js +20 -8
  124. package/server/services/memory/embeddings.js +151 -90
  125. package/server/services/memory/ingestion.js +50 -9
  126. package/server/services/memory/ingestion_documents.js +13 -3
  127. package/server/services/memory/manager.js +52 -19
  128. package/server/services/messaging/automation.js +85 -10
  129. package/server/services/messaging/formatting_guides.js +7 -4
  130. package/server/services/messaging/http_platforms.js +33 -13
  131. package/server/services/messaging/inbound_queue.js +78 -24
  132. package/server/services/messaging/inbound_store.js +224 -0
  133. package/server/services/messaging/manager.js +326 -51
  134. package/server/services/messaging/typing_keepalive.js +5 -2
  135. package/server/services/messaging/whatsapp.js +22 -14
  136. package/server/services/network/http.js +210 -0
  137. package/server/services/network/safe_request.js +307 -0
  138. package/server/services/runtime/backends/local-vm.js +227 -67
  139. package/server/services/runtime/docker-vm-manager.js +9 -0
  140. package/server/services/runtime/guest_bootstrap.js +30 -4
  141. package/server/services/runtime/guest_image.js +43 -12
  142. package/server/services/runtime/manager.js +77 -23
  143. package/server/services/runtime/validation.js +7 -6
  144. package/server/services/security/tool_categories.js +6 -0
  145. package/server/services/social_reach/channels/github.js +10 -4
  146. package/server/services/social_reach/channels/reddit.js +4 -4
  147. package/server/services/social_reach/channels/rss.js +2 -2
  148. package/server/services/social_reach/channels/social_video.js +12 -7
  149. package/server/services/social_reach/channels/v2ex.js +21 -8
  150. package/server/services/social_reach/channels/x.js +2 -2
  151. package/server/services/social_reach/channels/xueqiu.js +5 -5
  152. package/server/services/social_reach/service.js +9 -6
  153. package/server/services/social_reach/utils.js +65 -14
  154. package/server/services/social_video/service.js +160 -50
  155. package/server/services/tasks/integration_runtime.js +18 -8
  156. package/server/services/tasks/runtime.js +39 -4
  157. package/server/services/voice/agentBridge.js +17 -4
  158. package/server/services/voice/bufferedLiveRelayAdapter.js +5 -0
  159. package/server/services/voice/liveSession.js +31 -0
  160. package/server/services/voice/message.js +1 -1
  161. package/server/services/voice/openaiSpeech.js +33 -8
  162. package/server/services/voice/providers.js +233 -151
  163. package/server/services/voice/runtime.js +2 -2
  164. package/server/services/voice/runtimeManager.js +118 -20
  165. package/server/services/voice/turnRunner.js +6 -0
  166. package/server/services/wearable/firmware_manifest.js +51 -13
  167. package/server/services/wearable/service.js +1 -0
  168. package/server/utils/abort.js +96 -0
  169. package/server/utils/cloud-security.js +110 -3
  170. package/server/utils/files.js +31 -0
  171. package/server/utils/image_payload.js +95 -0
  172. package/server/utils/retry.js +107 -0
@@ -1,7 +1,12 @@
1
+ 'use strict';
2
+
1
3
  const path = require('path');
2
4
  const fs = require('fs');
5
+ const crypto = require('crypto');
3
6
  const { spawn } = require('child_process');
4
7
  const { DATA_DIR } = require('../../../runtime/paths');
8
+ const { validateCloudUrlWithDns } = require('../../utils/cloud-security');
9
+ const { runProcess } = require('../android/process');
5
10
  const {
6
11
  chooseBrowserIdentity,
7
12
  detectBotChallenge,
@@ -127,40 +132,66 @@ function resolveBrowserExecutablePath() {
127
132
  return platformCandidates.find((candidate) => fs.existsSync(candidate)) || null;
128
133
  }
129
134
 
130
- function installPlaywrightBrowserBinary(browserName) {
135
+ async function installPlaywrightBrowserBinary(browserName, options = {}) {
131
136
  const packageRoot = path.dirname(require.resolve('playwright-chromium/package.json'));
132
137
  const cliPath = path.join(packageRoot, 'cli.js');
133
- return new Promise((resolve, reject) => {
134
- const args = [cliPath, 'install', '--no-shell', browserName];
135
- const child = spawn(process.execPath, args, {
136
- stdio: ['ignore', 'pipe', 'pipe'],
137
- });
138
- let stdout = '';
139
- let stderr = '';
138
+ await runProcess(process.execPath, [cliPath, 'install', '--no-shell', browserName], {
139
+ signal: options.signal,
140
+ timeoutMs: 10 * 60 * 1000,
141
+ maxOutputBytes: 32 * 1024 * 1024,
142
+ });
143
+ }
140
144
 
141
- child.stdout.on('data', (data) => {
142
- stdout += data.toString();
143
- });
144
- child.stderr.on('data', (data) => {
145
- stderr += data.toString();
146
- });
147
- child.on('error', (error) => {
148
- const detail = String(error?.message || `playwright install ${browserName} failed`).trim();
149
- reject(new Error(detail));
150
- });
151
- child.on('close', (code) => {
152
- if (code === 0) {
153
- resolve();
154
- return;
155
- }
156
- const detail = String(stderr || stdout || `playwright install ${browserName} exited with code ${code ?? 'unknown'}`).trim();
157
- reject(new Error(detail));
158
- });
145
+ function createAbortError(signal, fallback = 'Browser operation aborted.') {
146
+ if (signal?.reason instanceof Error) return signal.reason;
147
+ const error = new Error(String(signal?.reason || fallback));
148
+ error.name = 'AbortError';
149
+ error.code = 'ABORT_ERR';
150
+ return error;
151
+ }
152
+
153
+ function throwIfAborted(signal) {
154
+ if (signal?.aborted) throw createAbortError(signal);
155
+ }
156
+
157
+ function sleep(ms, signal = null) {
158
+ throwIfAborted(signal);
159
+ return new Promise((resolve, reject) => {
160
+ const timer = setTimeout(() => {
161
+ signal?.removeEventListener('abort', onAbort);
162
+ resolve();
163
+ }, ms);
164
+ const onAbort = () => {
165
+ clearTimeout(timer);
166
+ reject(createAbortError(signal));
167
+ };
168
+ signal?.addEventListener('abort', onAbort, { once: true });
169
+ if (signal?.aborted) onAbort();
159
170
  });
160
171
  }
161
172
 
162
- function sleep(ms) {
163
- return new Promise(r => setTimeout(r, ms));
173
+ function isAbortError(error, signal = null) {
174
+ return Boolean(
175
+ signal?.aborted
176
+ || error?.name === 'AbortError'
177
+ || error?.code === 'ABORT_ERR',
178
+ );
179
+ }
180
+
181
+ async function raceWithSignal(promise, signal) {
182
+ throwIfAborted(signal);
183
+ if (!signal) return promise;
184
+ let onAbort;
185
+ const aborted = new Promise((_, reject) => {
186
+ onAbort = () => reject(createAbortError(signal));
187
+ signal.addEventListener('abort', onAbort, { once: true });
188
+ if (signal.aborted) onAbort();
189
+ });
190
+ try {
191
+ return await Promise.race([promise, aborted]);
192
+ } finally {
193
+ signal.removeEventListener('abort', onAbort);
194
+ }
164
195
  }
165
196
 
166
197
  async function waitForFile(filePath, options = {}) {
@@ -171,7 +202,7 @@ async function waitForFile(filePath, options = {}) {
171
202
  }
172
203
  const startedAt = Date.now();
173
204
  while (Date.now() - startedAt < timeoutMs) {
174
- await sleep(intervalMs);
205
+ await sleep(intervalMs, options.signal);
175
206
  if (fs.existsSync(filePath)) {
176
207
  return true;
177
208
  }
@@ -197,6 +228,17 @@ function normalizeWaitUntil(waitUntil) {
197
228
  return 'domcontentloaded';
198
229
  }
199
230
 
231
+ function normalizePointCoordinate(value, label) {
232
+ if (value == null || value === '') {
233
+ throw new Error(`${label} coordinate is required.`);
234
+ }
235
+ const normalized = Number(value);
236
+ if (!Number.isFinite(normalized)) {
237
+ throw new Error(`${label} coordinate must be a finite number.`);
238
+ }
239
+ return Math.round(normalized);
240
+ }
241
+
200
242
  function clearChromiumSingletonLocks(profileDir) {
201
243
  const lockEntries = [
202
244
  'SingletonLock',
@@ -213,6 +255,18 @@ function clearChromiumSingletonLocks(profileDir) {
213
255
  }
214
256
  }
215
257
 
258
+ function chooseVirtualDisplay() {
259
+ for (let number = 90; number < 200; number += 1) {
260
+ if (
261
+ !fs.existsSync(`/tmp/.X11-unix/X${number}`)
262
+ && !fs.existsSync(`/tmp/.X${number}-lock`)
263
+ ) {
264
+ return `:${number}`;
265
+ }
266
+ }
267
+ throw new Error('No free X11 display is available for the browser.');
268
+ }
269
+
216
270
  class BrowserController {
217
271
  constructor(options = {}) {
218
272
  this.io = options.io || null;
@@ -226,9 +280,16 @@ class BrowserController {
226
280
  this.page = null;
227
281
  this.displayProcess = null;
228
282
  this.displayValue = process.env.DISPLAY || null;
283
+ this._managedDisplayValue = null;
229
284
  this.launching = false;
230
285
  this.launchPromise = null;
231
286
  this.browserBinaryInstallPromise = null;
287
+ this._launchAbortController = null;
288
+ this._urlValidator = options.urlValidator || validateCloudUrlWithDns;
289
+ this._boundPages = new WeakSet();
290
+ this._closing = false;
291
+ this._closePromise = null;
292
+ this._protectedCredentialFill = null;
232
293
  this.headless = false;
233
294
  this.profileDir = path.join(BROWSER_PROFILE_ROOT, this.userId || 'default');
234
295
  if (!fs.existsSync(this.profileDir)) fs.mkdirSync(this.profileDir, { recursive: true });
@@ -251,6 +312,122 @@ class BrowserController {
251
312
  return this.close();
252
313
  }
253
314
 
315
+ _contextIsOpen() {
316
+ if (!this.context) return false;
317
+ if (typeof this.context.isClosed === 'function' && this.context.isClosed()) return false;
318
+ return !this.browser
319
+ || typeof this.browser.isConnected !== 'function'
320
+ || this.browser.isConnected();
321
+ }
322
+
323
+ _clearBrowserReferences(context, browser) {
324
+ if (context && this.context !== context) return;
325
+ if (!context && browser && this.browser !== browser) return;
326
+ this.context = null;
327
+ this.browser = null;
328
+ this.page = null;
329
+ }
330
+
331
+ _bindPage(page, options = {}) {
332
+ if (!page) return;
333
+ if (!this._boundPages.has(page)) {
334
+ this._boundPages.add(page);
335
+ const clearPage = () => {
336
+ if (this.page === page) this.page = null;
337
+ };
338
+ page.on?.('close', clearPage);
339
+ page.on?.('crash', clearPage);
340
+ }
341
+ if (options.makeActive !== false) this.page = page;
342
+ }
343
+
344
+ _bindContextLifecycle(context, browser) {
345
+ context.on?.('close', () => this._clearBrowserReferences(context, browser));
346
+ context.on?.('page', (page) => {
347
+ this._bindPage(page);
348
+ this._applyStealthToPage(page).catch(() => {});
349
+ });
350
+ browser?.on?.('disconnected', () => this._clearBrowserReferences(context, browser));
351
+ for (const page of context.pages?.() || []) this._bindPage(page, { makeActive: false });
352
+ }
353
+
354
+ async _networkUrlAllowed(url) {
355
+ let parsed;
356
+ try {
357
+ parsed = new URL(String(url || ''));
358
+ } catch {
359
+ return false;
360
+ }
361
+ const protocol = parsed.protocol.toLowerCase();
362
+ if (protocol === 'about:' && parsed.href === 'about:blank') return true;
363
+ if (protocol === 'blob:' || protocol === 'data:') return true;
364
+ if (protocol === 'ws:' || protocol === 'wss:') {
365
+ parsed.protocol = protocol === 'ws:' ? 'http:' : 'https:';
366
+ } else if (protocol !== 'http:' && protocol !== 'https:') {
367
+ return false;
368
+ }
369
+ const result = await this._urlValidator(parsed.href);
370
+ return result?.allowed === true;
371
+ }
372
+
373
+ async _assertNavigationAllowed(url, options = {}) {
374
+ let result = null;
375
+ try {
376
+ result = await this._urlValidator(String(url || ''), { signal: options.signal });
377
+ } catch (error) {
378
+ if (isAbortError(error, options.signal)) throw error;
379
+ }
380
+ if (result?.allowed !== true) {
381
+ const error = new Error('This URL is not permitted.');
382
+ error.code = 'URL_BLOCKED';
383
+ throw error;
384
+ }
385
+ }
386
+
387
+ async _installNetworkGuard(context) {
388
+ await context.route('**/*', async (route) => {
389
+ let allowed = false;
390
+ try {
391
+ allowed = await this._networkUrlAllowed(route.request().url());
392
+ } catch {}
393
+ if (!allowed) {
394
+ await route.abort('blockedbyclient').catch(() => {});
395
+ return;
396
+ }
397
+ await route.continue().catch(() => {});
398
+ });
399
+
400
+ if (typeof context.routeWebSocket === 'function') {
401
+ await context.routeWebSocket('**', async (webSocket) => {
402
+ let allowed = false;
403
+ try {
404
+ allowed = await this._networkUrlAllowed(webSocket.url());
405
+ } catch {}
406
+ if (!allowed) {
407
+ await webSocket.close({ code: 1008, reason: 'Blocked by network policy' }).catch(() => {});
408
+ return;
409
+ }
410
+ webSocket.connectToServer();
411
+ });
412
+ }
413
+ }
414
+
415
+ async _withPageCancellation(page, signal, operation) {
416
+ throwIfAborted(signal);
417
+ if (!signal) return operation();
418
+ const closeOnAbort = () => {
419
+ if (!page?.isClosed?.()) {
420
+ page.close({ runBeforeUnload: false, reason: 'Browser operation cancelled' }).catch(() => {});
421
+ }
422
+ };
423
+ signal.addEventListener('abort', closeOnAbort, { once: true });
424
+ try {
425
+ return await raceWithSignal(Promise.resolve().then(operation), signal);
426
+ } finally {
427
+ signal.removeEventListener('abort', closeOnAbort);
428
+ }
429
+ }
430
+
254
431
  async _applyStealthToPage(page) {
255
432
  const ua = this._userAgent;
256
433
  const vp = this._viewport;
@@ -308,7 +485,6 @@ class BrowserController {
308
485
  arr.item = i => arr[i];
309
486
  arr.namedItem = n => arr.find(p => p.name === n) || null;
310
487
  arr.refresh = () => {};
311
- Object.defineProperty(arr, 'length', { get: () => arr.length });
312
488
  return arr;
313
489
  }
314
490
  });
@@ -345,17 +521,6 @@ class BrowserController {
345
521
  return originalFillText.apply(this, args);
346
522
  };
347
523
 
348
- // Media Devices Spoofing
349
- if (navigator.mediaDevices && navigator.mediaDevices.enumerateDevices) {
350
- const originalEnumerateDevices = navigator.mediaDevices.enumerateDevices.bind(navigator.mediaDevices);
351
- navigator.mediaDevices.enumerateDevices = async () => {
352
- return [
353
- { kind: 'audioinput', deviceId: 'default', groupId: 'a', label: 'MacBook Pro Microphone' },
354
- { kind: 'audiooutput', deviceId: 'default', groupId: 'b', label: 'MacBook Pro Speakers' },
355
- { kind: 'videoinput', deviceId: 'default', groupId: 'c', label: 'FaceTime HD Camera' }
356
- ];
357
- };
358
- }
359
524
  })();
360
525
  `;
361
526
  if (typeof page.evaluateOnNewDocument === 'function') {
@@ -365,23 +530,42 @@ class BrowserController {
365
530
  }
366
531
  }
367
532
 
368
- async ensureBrowser() {
369
- if (this.browser && this.browser.isConnected()) return;
533
+ async ensureBrowser(options = {}) {
534
+ let signal = options.signal;
535
+ throwIfAborted(signal);
536
+ if (this._closePromise) await raceWithSignal(this._closePromise, signal);
537
+ if (this._contextIsOpen()) return;
370
538
  if (this.launchPromise) {
371
- await this.launchPromise;
539
+ await raceWithSignal(this.launchPromise, signal);
372
540
  return;
373
541
  }
374
542
 
543
+ const staleContext = this.context;
544
+ const staleBrowser = this.browser;
545
+ this._clearBrowserReferences(staleContext, staleBrowser);
546
+ if (staleContext) await staleContext.close().catch(() => {});
547
+ else if (staleBrowser) await staleBrowser.close().catch(() => {});
548
+
549
+ const launchAbortController = new AbortController();
550
+ this._launchAbortController = launchAbortController;
551
+ const externalSignal = signal;
552
+ const forwardAbort = () => launchAbortController.abort(externalSignal.reason);
553
+ externalSignal?.addEventListener('abort', forwardAbort, { once: true });
554
+ if (externalSignal?.aborted) forwardAbort();
555
+ signal = launchAbortController.signal;
556
+
375
557
  this.launching = true;
376
558
  this.launchPromise = (async () => {
377
559
  const runtimeReady = await waitForFile(BROWSER_READY_MARKER, {
378
560
  timeoutMs: 10 * 60 * 1000,
379
561
  intervalMs: 1000,
562
+ signal,
380
563
  });
381
564
  if (!runtimeReady) {
382
565
  throw new Error('Browser runtime provisioning is still in progress inside the VM. Retry shortly.');
383
566
  }
384
- await this.ensureVirtualDisplay();
567
+ await this.ensureVirtualDisplay({ signal });
568
+ throwIfAborted(signal);
385
569
 
386
570
  this._identity = chooseBrowserIdentity(this.userId || this.profileDir);
387
571
  this._userAgent = this._identity.userAgent;
@@ -394,10 +578,10 @@ class BrowserController {
394
578
  let executablePath = resolveBrowserExecutablePath();
395
579
  if (!executablePath) {
396
580
  if (!this.browserBinaryInstallPromise) {
397
- this.browserBinaryInstallPromise = installPlaywrightBrowserBinary(this.engine);
581
+ this.browserBinaryInstallPromise = installPlaywrightBrowserBinary(this.engine, { signal });
398
582
  }
399
583
  try {
400
- await this.browserBinaryInstallPromise;
584
+ await raceWithSignal(this.browserBinaryInstallPromise, signal);
401
585
  } finally {
402
586
  this.browserBinaryInstallPromise = null;
403
587
  }
@@ -444,7 +628,7 @@ class BrowserController {
444
628
 
445
629
  const playwright = require('playwright-chromium');
446
630
  clearChromiumSingletonLocks(this.profileDir);
447
- this.context = await playwright.chromium.launchPersistentContext(this.profileDir, {
631
+ const launchPromise = playwright.chromium.launchPersistentContext(this.profileDir, {
448
632
  headless: false,
449
633
  executablePath,
450
634
  env: launchEnv,
@@ -453,98 +637,137 @@ class BrowserController {
453
637
  userAgent: this._userAgent,
454
638
  locale: 'en-US',
455
639
  ignoreHTTPSErrors: false,
640
+ serviceWorkers: 'block',
456
641
  timeout: 120000,
457
642
  });
458
- this.browser = typeof this.context.browser === 'function' ? this.context.browser() : null;
643
+ let context;
644
+ try {
645
+ context = await raceWithSignal(launchPromise, signal);
646
+ } catch (error) {
647
+ launchPromise.then((lateContext) => lateContext.close().catch(() => {})).catch(() => {});
648
+ throw error;
649
+ }
650
+ const browser = typeof context.browser === 'function' ? context.browser() : null;
651
+ this.context = context;
652
+ this.browser = browser;
653
+ this._bindContextLifecycle(context, browser);
459
654
 
460
655
  // Cloud security: deny access to local devices on every page in this context.
461
- await this.context.addInitScript(DEVICE_DENY_SCRIPT);
656
+ await context.addInitScript(DEVICE_DENY_SCRIPT);
657
+ await this._installNetworkGuard(context);
462
658
 
463
- this.page = this.context.pages()[0] || await this.context.newPage();
659
+ this.page = context.pages()[0] || await context.newPage();
660
+ this._bindPage(this.page);
464
661
  await this._applyStealthToPage(this.page);
465
662
  })();
466
663
 
467
664
  try {
468
- await this.launchPromise;
665
+ await raceWithSignal(this.launchPromise, signal);
666
+ } catch (error) {
667
+ const failedContext = this.context;
668
+ const failedBrowser = this.browser;
669
+ this._clearBrowserReferences(failedContext, failedBrowser);
670
+ if (failedContext) await failedContext.close().catch(() => {});
671
+ else if (failedBrowser) await failedBrowser.close().catch(() => {});
672
+ await this._stopVirtualDisplay();
673
+ throw error;
469
674
  } finally {
675
+ externalSignal?.removeEventListener('abort', forwardAbort);
676
+ if (this._launchAbortController === launchAbortController) {
677
+ this._launchAbortController = null;
678
+ }
470
679
  this.launchPromise = null;
471
680
  this.launching = false;
472
681
  }
473
682
  }
474
683
 
475
- async ensurePage() {
476
- await this.ensureBrowser();
684
+ async ensurePage(options = {}) {
685
+ const signal = options.signal;
686
+ await this.ensureBrowser(options);
687
+ throwIfAborted(signal);
477
688
  if (!this.page || this.page.isClosed()) {
689
+ let pagePromise;
478
690
  if (this.context && typeof this.context.newPage === 'function') {
479
- this.page = await this.context.newPage();
691
+ pagePromise = this.context.newPage();
480
692
  } else {
481
- this.page = await this.browser.newPage();
693
+ pagePromise = this.browser.newPage();
694
+ }
695
+ try {
696
+ this.page = await raceWithSignal(pagePromise, signal);
697
+ } catch (error) {
698
+ pagePromise.then((latePage) => latePage.close().catch(() => {})).catch(() => {});
699
+ throw error;
482
700
  }
701
+ this._bindPage(this.page);
483
702
  await this._applyStealthToPage(this.page);
484
703
  }
485
704
  return this.page;
486
705
  }
487
706
 
488
707
  async takeScreenshot(options = {}) {
489
- const page = await this.ensurePage();
490
- let artifactRecord = null;
491
- let filename = `screenshot_${Date.now()}.png`;
492
- let filepath = path.join(SCREENSHOTS_DIR, filename);
493
- if (this.artifactStore && this.userId != null) {
494
- artifactRecord = this.artifactStore.allocateFile(this.userId, {
495
- kind: 'browser-screenshot',
496
- backend: this.runtimeBackend,
497
- extension: 'png',
498
- contentType: 'image/png',
499
- filenameBase: 'browser-screenshot',
500
- metadata: {
501
- selector: options.selector || null,
502
- fullPage: options.fullPage === true,
503
- },
504
- });
505
- filepath = artifactRecord.storagePath;
506
- filename = path.basename(filepath);
507
- }
708
+ const page = await this.ensurePage(options);
709
+ return this._withPageCancellation(page, options.signal, async () => {
710
+ let artifactRecord = null;
711
+ let filename = `screenshot_${Date.now()}.png`;
712
+ let filepath = path.join(SCREENSHOTS_DIR, filename);
713
+ if (this.artifactStore && this.userId != null) {
714
+ artifactRecord = this.artifactStore.allocateFile(this.userId, {
715
+ kind: 'browser-screenshot',
716
+ backend: this.runtimeBackend,
717
+ extension: 'png',
718
+ contentType: 'image/png',
719
+ filenameBase: 'browser-screenshot',
720
+ metadata: {
721
+ selector: options.selector || null,
722
+ fullPage: options.fullPage === true,
723
+ },
724
+ });
725
+ filepath = artifactRecord.storagePath;
726
+ filename = path.basename(filepath);
727
+ }
508
728
 
509
- const screenshotOptions = { path: filepath, type: 'png' };
510
- if (options.fullPage) screenshotOptions.fullPage = true;
511
- if (options.selector) {
512
- const element = await page.$(options.selector);
513
- if (element) {
514
- await element.screenshot(screenshotOptions);
729
+ const screenshotOptions = { path: filepath, type: 'png' };
730
+ if (options.fullPage) screenshotOptions.fullPage = true;
731
+ if (options.selector) {
732
+ const element = await page.$(options.selector);
733
+ if (element) {
734
+ await element.screenshot(screenshotOptions);
735
+ } else {
736
+ await page.screenshot(screenshotOptions);
737
+ }
515
738
  } else {
516
739
  await page.screenshot(screenshotOptions);
517
740
  }
518
- } else {
519
- await page.screenshot(screenshotOptions);
520
- }
521
741
 
522
- if (artifactRecord) {
523
- this.artifactStore.finalizeFile(artifactRecord.artifactId, filepath);
524
- }
742
+ if (artifactRecord) {
743
+ this.artifactStore.finalizeFile(artifactRecord.artifactId, filepath);
744
+ }
525
745
 
526
- return {
527
- screenshotPath: artifactRecord ? artifactRecord.url : `/screenshots/${filename}`,
528
- artifactId: artifactRecord?.artifactId || null,
529
- filename,
530
- fullPath: filepath,
531
- };
746
+ return {
747
+ screenshotPath: artifactRecord ? artifactRecord.url : `/screenshots/${filename}`,
748
+ artifactId: artifactRecord?.artifactId || null,
749
+ filename,
750
+ fullPath: filepath,
751
+ };
752
+ });
532
753
  }
533
754
 
534
755
  async screenshotJpeg(quality = 80, options = {}) {
535
- const page = await this.ensurePage();
536
- const screenshotOptions = {
537
- type: 'jpeg',
538
- quality: Math.min(95, Math.max(30, Math.floor(Number(quality) || 80))),
539
- fullPage: options.fullPage === true,
540
- };
541
- if (options.selector) {
542
- const element = await page.$(options.selector);
543
- if (element) {
544
- return element.screenshot(screenshotOptions);
756
+ const page = await this.ensurePage(options);
757
+ return this._withPageCancellation(page, options.signal, async () => {
758
+ const screenshotOptions = {
759
+ type: 'jpeg',
760
+ quality: Math.min(95, Math.max(30, Math.floor(Number(quality) || 80))),
761
+ fullPage: options.fullPage === true,
762
+ };
763
+ if (options.selector) {
764
+ const element = await page.$(options.selector);
765
+ if (element) {
766
+ return element.screenshot(screenshotOptions);
767
+ }
545
768
  }
546
- }
547
- return page.screenshot(screenshotOptions);
769
+ return page.screenshot(screenshotOptions);
770
+ });
548
771
  }
549
772
 
550
773
  async _navigatePage(page, url, options = {}) {
@@ -552,9 +775,21 @@ class BrowserController {
552
775
  const waitUntil = normalizeWaitUntil(options.waitUntil);
553
776
  if (referrerMode === 'current' && page.url() && page.url() !== 'about:blank') {
554
777
  const previousUrl = page.url();
555
- await page.evaluate((nextUrl) => { window.location.href = nextUrl; }, url);
556
- await page.waitForFunction((oldUrl) => window.location.href !== oldUrl, previousUrl, { timeout: 10000 }).catch(() => {});
557
- await page.waitForLoadState(waitUntil, { timeout: 30000 }).catch(() => {});
778
+ const urlChanged = typeof page.waitForURL === 'function'
779
+ ? page.waitForURL(
780
+ (nextUrl) => String(nextUrl) !== previousUrl,
781
+ { waitUntil, timeout: 30000 },
782
+ )
783
+ : page.waitForFunction(
784
+ (oldUrl) => window.location.href !== oldUrl,
785
+ previousUrl,
786
+ { timeout: 30000 },
787
+ );
788
+ await Promise.all([
789
+ urlChanged,
790
+ page.evaluate((nextUrl) => { window.location.href = nextUrl; }, url),
791
+ ]);
792
+ await page.waitForLoadState(waitUntil, { timeout: 30000 });
558
793
  return null;
559
794
  }
560
795
 
@@ -581,80 +816,89 @@ class BrowserController {
581
816
  }
582
817
 
583
818
  async navigate(url, options = {}) {
584
- const page = await this.ensurePage();
585
-
819
+ let page = null;
586
820
  try {
587
- const requestedReferrerMode = normalizeReferrerMode(options.referrerMode);
588
- let activeReferrerMode = requestedReferrerMode;
589
- let response = await this._navigatePage(page, url, {
590
- ...options,
591
- referrerMode: activeReferrerMode,
592
- });
593
-
594
- if (options.waitFor) {
595
- await page.waitForSelector(options.waitFor, { timeout: 10000 }).catch(() => { });
596
- }
597
-
598
- // Simulate human reading delay
599
- await sleep(rand(700, 1800));
600
-
601
- let rawHtml = await page.content();
602
- const { extractForLLM } = require('./contentExtractor');
603
- let extraction = extractForLLM(rawHtml, { url: page.url() });
604
- let botDetection = await this._getBotDetection(page, rawHtml, extraction.markdown);
605
- let challengeRetried = false;
606
-
607
- if (
608
- botDetection.detected
609
- && normalizeChallengeRetry(options.challengeRetry)
610
- && requestedReferrerMode === 'direct'
611
- ) {
612
- challengeRetried = true;
613
- activeReferrerMode = 'google';
614
- await sleep(rand(1200, 2600));
615
- response = await this._navigatePage(page, url, {
821
+ await this._assertNavigationAllowed(url, options);
822
+ page = await this.ensurePage(options);
823
+ return await this._withPageCancellation(page, options.signal, async () => {
824
+ const requestedReferrerMode = normalizeReferrerMode(options.referrerMode);
825
+ let activeReferrerMode = requestedReferrerMode;
826
+ let response = await this._navigatePage(page, url, {
616
827
  ...options,
617
828
  referrerMode: activeReferrerMode,
618
829
  });
830
+
619
831
  if (options.waitFor) {
620
- await page.waitForSelector(options.waitFor, { timeout: 10000 }).catch(() => { });
832
+ await page.waitForSelector(options.waitFor, { timeout: 10000 });
621
833
  }
622
- await sleep(rand(900, 2200));
623
- rawHtml = await page.content();
624
- extraction = extractForLLM(rawHtml, { url: page.url() });
625
- botDetection = await this._getBotDetection(page, rawHtml, extraction.markdown);
626
- }
627
834
 
628
- const title = await page.title();
629
- const currentUrl = page.url();
835
+ // Simulate human reading delay.
836
+ await sleep(rand(700, 1800), options.signal);
837
+
838
+ let rawHtml = await page.content();
839
+ const { extractForLLM } = require('./contentExtractor');
840
+ let extraction = extractForLLM(rawHtml, { url: page.url() });
841
+ let botDetection = await this._getBotDetection(page, rawHtml, extraction.markdown);
842
+ let challengeRetried = false;
843
+
844
+ if (
845
+ botDetection.detected
846
+ && normalizeChallengeRetry(options.challengeRetry)
847
+ && requestedReferrerMode === 'direct'
848
+ ) {
849
+ challengeRetried = true;
850
+ activeReferrerMode = 'google';
851
+ await sleep(rand(1200, 2600), options.signal);
852
+ response = await this._navigatePage(page, url, {
853
+ ...options,
854
+ referrerMode: activeReferrerMode,
855
+ });
856
+ if (options.waitFor) {
857
+ await page.waitForSelector(options.waitFor, { timeout: 10000 });
858
+ }
859
+ await sleep(rand(900, 2200), options.signal);
860
+ rawHtml = await page.content();
861
+ extraction = extractForLLM(rawHtml, { url: page.url() });
862
+ botDetection = await this._getBotDetection(page, rawHtml, extraction.markdown);
863
+ }
630
864
 
631
- let screenshot = null;
632
- if (options.screenshot !== false) {
633
- screenshot = await this.takeScreenshot({ fullPage: options.fullPage });
634
- }
865
+ const title = await page.title();
866
+ const currentUrl = page.url();
635
867
 
636
- return {
637
- title,
638
- url: currentUrl,
639
- status: response?.status() || 0,
640
- pageContent: extraction.markdown,
641
- botDetection,
642
- referrerMode: activeReferrerMode,
643
- challengeRetried,
644
- screenshotPath: screenshot?.screenshotPath || null,
645
- artifactId: screenshot?.artifactId || null,
646
- fullPath: screenshot?.fullPath || null
647
- };
868
+ let screenshot = null;
869
+ if (options.screenshot !== false) {
870
+ screenshot = await this.takeScreenshot({
871
+ fullPage: options.fullPage,
872
+ signal: options.signal,
873
+ });
874
+ }
875
+
876
+ return {
877
+ title,
878
+ url: currentUrl,
879
+ status: response?.status() || 0,
880
+ pageContent: extraction.markdown,
881
+ botDetection,
882
+ referrerMode: activeReferrerMode,
883
+ challengeRetried,
884
+ screenshotPath: screenshot?.screenshotPath || null,
885
+ artifactId: screenshot?.artifactId || null,
886
+ fullPath: screenshot?.fullPath || null,
887
+ };
888
+ });
648
889
  } catch (err) {
890
+ if (isAbortError(err, options.signal)) throw err;
649
891
  let screenshot = null;
650
- try { screenshot = await this.takeScreenshot(); } catch { }
892
+ if (page && !page.isClosed()) {
893
+ try { screenshot = await this.takeScreenshot(); } catch {}
894
+ }
651
895
  return {
652
896
  error: err.message,
653
897
  url,
654
898
  botDetection: { detected: false, provider: null },
655
899
  screenshotPath: screenshot?.screenshotPath || null,
656
900
  artifactId: screenshot?.artifactId || null,
657
- fullPath: screenshot?.fullPath || null
901
+ fullPath: screenshot?.fullPath || null,
658
902
  };
659
903
  }
660
904
  }
@@ -668,7 +912,7 @@ class BrowserController {
668
912
  for (const point of path) {
669
913
  await page.mouse.move(point.x, point.y);
670
914
  if (!options.fast) {
671
- await sleep(rand(2, 10));
915
+ await sleep(rand(2, 10), options.signal);
672
916
  }
673
917
  }
674
918
  this._mousePosition = target;
@@ -689,271 +933,327 @@ class BrowserController {
689
933
  };
690
934
  }
691
935
 
692
- async click(selector, text, screenshot = true) {
693
- const page = await this.ensurePage();
936
+ async click(selector, text, screenshot = true, options = {}) {
937
+ const page = await this.ensurePage(options);
694
938
 
695
939
  try {
696
- let target = null;
697
-
698
- if (text && !selector) {
699
- const elements = await page.$$('a, button, [role="button"], input[type="submit"], [onclick]');
700
- for (const el of elements) {
701
- const elText = await page.evaluate(e => e.innerText || e.value || e.getAttribute('aria-label') || '', el);
702
- if (elText.toLowerCase().includes(text.toLowerCase())) {
703
- target = el;
704
- break;
940
+ return await this._withPageCancellation(page, options.signal, async () => {
941
+ let target = null;
942
+
943
+ if (text && !selector) {
944
+ const elements = await page.$$('a, button, [role="button"], input[type="submit"], [onclick]');
945
+ for (const el of elements) {
946
+ const elText = await page.evaluate(e => e.innerText || e.value || e.getAttribute('aria-label') || '', el);
947
+ if (elText.toLowerCase().includes(String(text).toLowerCase())) {
948
+ target = el;
949
+ break;
950
+ }
705
951
  }
952
+ if (!target) return { error: `No clickable element found with text: ${text}` };
953
+ } else if (selector) {
954
+ target = await page.$(selector);
955
+ if (!target) return { error: `Element not found: ${selector}` };
956
+ } else {
957
+ return { error: 'Either selector or text required' };
706
958
  }
707
- if (!target) return { error: `No clickable element found with text: ${text}` };
708
- } else if (selector) {
709
- target = await page.$(selector);
710
- if (!target) return { error: `Element not found: ${selector}` };
711
- } else {
712
- return { error: 'Either selector or text required' };
713
- }
714
959
 
715
- const point = await this._pointForElement(target);
716
- if (!point) return { error: 'Element has no visible clickable area' };
960
+ const point = await this._pointForElement(target);
961
+ if (!point) return { error: 'Element has no visible clickable area' };
717
962
 
718
- await this._moveMouseTo(page, point.x, point.y);
719
- await sleep(rand(170, 320));
720
- await page.mouse.down();
721
- await sleep(rand(80, 260));
722
- await page.mouse.up();
963
+ await this._moveMouseTo(page, point.x, point.y, { signal: options.signal });
964
+ await sleep(rand(170, 320), options.signal);
965
+ await page.mouse.down();
966
+ await sleep(rand(80, 260), options.signal);
967
+ await page.mouse.up();
723
968
 
724
- await sleep(rand(800, 1800));
969
+ await sleep(rand(800, 1800), options.signal);
725
970
 
726
- let screenshotResult = null;
727
- if (screenshot) screenshotResult = await this.takeScreenshot();
971
+ let screenshotResult = null;
972
+ if (screenshot) screenshotResult = await this.takeScreenshot({ signal: options.signal });
728
973
 
729
- return {
730
- success: true,
731
- url: page.url(),
732
- title: await page.title(),
733
- screenshotPath: screenshotResult?.screenshotPath || null,
734
- artifactId: screenshotResult?.artifactId || null,
735
- fullPath: screenshotResult?.fullPath || null
736
- };
974
+ return {
975
+ success: true,
976
+ url: page.url(),
977
+ title: await page.title(),
978
+ screenshotPath: screenshotResult?.screenshotPath || null,
979
+ artifactId: screenshotResult?.artifactId || null,
980
+ fullPath: screenshotResult?.fullPath || null,
981
+ };
982
+ });
737
983
  } catch (err) {
984
+ if (isAbortError(err, options.signal)) throw err;
738
985
  return { error: err.message };
739
986
  }
740
987
  }
741
988
 
742
- async clickPoint(x, y, screenshot = true) {
743
- const page = await this.ensurePage();
989
+ async clickPoint(x, y, screenshot = true, options = {}) {
990
+ const page = await this.ensurePage(options);
744
991
 
745
992
  try {
746
- const px = Math.max(0, Math.round(Number(x) || 0));
747
- const py = Math.max(0, Math.round(Number(y) || 0));
748
- await this._moveMouseTo(page, px, py);
749
- await sleep(rand(90, 220));
750
- await page.mouse.down();
751
- await sleep(rand(70, 240));
752
- await page.mouse.up();
753
- await sleep(rand(500, 1200));
754
-
755
- let screenshotResult = null;
756
- if (screenshot) screenshotResult = await this.takeScreenshot();
757
-
758
- return {
759
- success: true,
760
- x: px,
761
- y: py,
762
- url: page.url(),
763
- title: await page.title(),
764
- screenshotPath: screenshotResult?.screenshotPath || null,
765
- artifactId: screenshotResult?.artifactId || null,
766
- fullPath: screenshotResult?.fullPath || null
767
- };
993
+ return await this._withPageCancellation(page, options.signal, async () => {
994
+ const px = Math.max(0, normalizePointCoordinate(x, 'x'));
995
+ const py = Math.max(0, normalizePointCoordinate(y, 'y'));
996
+ await this._moveMouseTo(page, px, py, { signal: options.signal });
997
+ await sleep(rand(90, 220), options.signal);
998
+ await page.mouse.down();
999
+ await sleep(rand(70, 240), options.signal);
1000
+ await page.mouse.up();
1001
+ await sleep(rand(500, 1200), options.signal);
1002
+
1003
+ let screenshotResult = null;
1004
+ if (screenshot) screenshotResult = await this.takeScreenshot({ signal: options.signal });
1005
+
1006
+ return {
1007
+ success: true,
1008
+ x: px,
1009
+ y: py,
1010
+ url: page.url(),
1011
+ title: await page.title(),
1012
+ screenshotPath: screenshotResult?.screenshotPath || null,
1013
+ artifactId: screenshotResult?.artifactId || null,
1014
+ fullPath: screenshotResult?.fullPath || null,
1015
+ };
1016
+ });
768
1017
  } catch (err) {
1018
+ if (isAbortError(err, options.signal)) throw err;
769
1019
  return { error: err.message };
770
1020
  }
771
1021
  }
772
1022
 
773
1023
  async hoverPoint(x, y, options = {}) {
774
- const page = await this.ensurePage();
1024
+ const page = await this.ensurePage(options);
775
1025
  try {
776
- const px = Math.max(0, Math.round(Number(x) || 0));
777
- const py = Math.max(0, Math.round(Number(y) || 0));
778
- await this._moveMouseTo(page, px, py, { fast: Number(options.steps) <= 1 });
779
- return {
780
- success: true,
781
- x: px,
782
- y: py,
783
- url: page.url(),
784
- title: await page.title()
785
- };
1026
+ return await this._withPageCancellation(page, options.signal, async () => {
1027
+ const px = Math.max(0, normalizePointCoordinate(x, 'x'));
1028
+ const py = Math.max(0, normalizePointCoordinate(y, 'y'));
1029
+ await this._moveMouseTo(page, px, py, {
1030
+ fast: Number(options.steps) <= 1,
1031
+ signal: options.signal,
1032
+ });
1033
+ return {
1034
+ success: true,
1035
+ x: px,
1036
+ y: py,
1037
+ url: page.url(),
1038
+ title: await page.title(),
1039
+ };
1040
+ });
786
1041
  } catch (err) {
1042
+ if (isAbortError(err, options.signal)) throw err;
787
1043
  return { error: err.message };
788
1044
  }
789
1045
  }
790
1046
 
791
- async scroll(deltaX = 0, deltaY = 0, screenshot = true) {
792
- const page = await this.ensurePage();
1047
+ async scroll(deltaX = 0, deltaY = 0, screenshot = true, options = {}) {
1048
+ const page = await this.ensurePage(options);
793
1049
 
794
1050
  try {
795
- const x = Math.max(10, Math.min(this._viewport.width - 10, this._mousePosition.x + rand(-80, 80)));
796
- const y = Math.max(10, Math.min(this._viewport.height - 10, this._mousePosition.y + rand(-80, 80)));
797
- await this._moveMouseTo(page, x, y);
798
- const totalX = Math.round(Number(deltaX) || 0);
799
- const totalY = Math.round(Number(deltaY) || 0);
800
- const chunks = Math.max(1, Math.min(6, Math.ceil(Math.max(Math.abs(totalX), Math.abs(totalY)) / 450)));
801
- for (let i = 0; i < chunks; i += 1) {
802
- await page.mouse.wheel({
803
- deltaX: Math.round(totalX / chunks),
804
- deltaY: Math.round(totalY / chunks),
805
- });
806
- await sleep(rand(120, 360));
807
- }
1051
+ return await this._withPageCancellation(page, options.signal, async () => {
1052
+ const x = Math.max(10, Math.min(this._viewport.width - 10, this._mousePosition.x + rand(-80, 80)));
1053
+ const y = Math.max(10, Math.min(this._viewport.height - 10, this._mousePosition.y + rand(-80, 80)));
1054
+ await this._moveMouseTo(page, x, y, { signal: options.signal });
1055
+ const totalX = Math.round(Number(deltaX) || 0);
1056
+ const totalY = Math.round(Number(deltaY) || 0);
1057
+ const chunks = Math.max(1, Math.min(6, Math.ceil(Math.max(Math.abs(totalX), Math.abs(totalY)) / 450)));
1058
+ for (let i = 0; i < chunks; i += 1) {
1059
+ await page.mouse.wheel({
1060
+ deltaX: Math.round(totalX / chunks),
1061
+ deltaY: Math.round(totalY / chunks),
1062
+ });
1063
+ await sleep(rand(120, 360), options.signal);
1064
+ }
808
1065
 
809
- let screenshotResult = null;
810
- if (screenshot) screenshotResult = await this.takeScreenshot();
1066
+ let screenshotResult = null;
1067
+ if (screenshot) screenshotResult = await this.takeScreenshot({ signal: options.signal });
811
1068
 
812
- return {
813
- success: true,
814
- url: page.url(),
815
- title: await page.title(),
816
- screenshotPath: screenshotResult?.screenshotPath || null,
817
- artifactId: screenshotResult?.artifactId || null,
818
- fullPath: screenshotResult?.fullPath || null
819
- };
1069
+ return {
1070
+ success: true,
1071
+ url: page.url(),
1072
+ title: await page.title(),
1073
+ screenshotPath: screenshotResult?.screenshotPath || null,
1074
+ artifactId: screenshotResult?.artifactId || null,
1075
+ fullPath: screenshotResult?.fullPath || null,
1076
+ };
1077
+ });
820
1078
  } catch (err) {
1079
+ if (isAbortError(err, options.signal)) throw err;
821
1080
  return { error: err.message };
822
1081
  }
823
1082
  }
824
1083
 
825
1084
  async type(selector, text, options = {}) {
826
- const page = await this.ensurePage();
1085
+ const page = await this.ensurePage(options);
827
1086
 
828
1087
  try {
829
- if (options.clear !== false) {
1088
+ return await this._withPageCancellation(page, options.signal, async () => {
1089
+ const value = String(text ?? '');
830
1090
  const element = await page.$(selector);
831
- if (element) {
832
- const point = await this._pointForElement(element);
833
- if (point) {
834
- await this._moveMouseTo(page, point.x, point.y);
835
- await sleep(rand(120, 260));
1091
+ if (!element) return { error: `Element not found: ${selector}` };
1092
+ const point = await this._pointForElement(element);
1093
+ if (point) {
1094
+ await this._moveMouseTo(page, point.x, point.y, { signal: options.signal });
1095
+ await sleep(rand(120, 260), options.signal);
1096
+ }
1097
+
1098
+ const locator = typeof page.locator === 'function' ? page.locator(selector) : null;
1099
+ if (options.clear !== false) {
1100
+ if (locator && typeof locator.fill === 'function') {
1101
+ await locator.fill('');
1102
+ } else {
1103
+ await page.click(selector, { clickCount: 3, delay: rand(40, 120) });
1104
+ await page.keyboard.press('Backspace');
836
1105
  }
837
1106
  }
838
- await page.click(selector, { clickCount: 3, delay: rand(40, 120) });
839
- await page.keyboard.press('Backspace');
840
- }
841
1107
 
842
- for (const char of text) {
843
- await page.type(selector, char, { delay: rand(45, 180) });
844
- }
1108
+ if (locator && typeof locator.pressSequentially === 'function') {
1109
+ await locator.pressSequentially(value, { delay: rand(45, 140) });
1110
+ } else {
1111
+ await page.type(selector, value, { delay: rand(45, 140) });
1112
+ }
845
1113
 
846
- if (options.pressEnter) {
847
- await page.keyboard.press('Enter');
848
- await sleep(1000);
849
- }
1114
+ if (options.pressEnter) {
1115
+ if (locator && typeof locator.press === 'function') await locator.press('Enter');
1116
+ else await page.keyboard.press('Enter');
1117
+ await sleep(1000, options.signal);
1118
+ }
850
1119
 
851
- let screenshotResult = null;
852
- if (options.screenshot !== false) screenshotResult = await this.takeScreenshot();
1120
+ let screenshotResult = null;
1121
+ if (options.screenshot !== false) {
1122
+ screenshotResult = await this.takeScreenshot({ signal: options.signal });
1123
+ }
853
1124
 
854
- return {
855
- success: true,
856
- typed: text,
857
- screenshotPath: screenshotResult?.screenshotPath || null,
858
- artifactId: screenshotResult?.artifactId || null,
859
- fullPath: screenshotResult?.fullPath || null
860
- };
1125
+ return {
1126
+ success: true,
1127
+ typed: value,
1128
+ screenshotPath: screenshotResult?.screenshotPath || null,
1129
+ artifactId: screenshotResult?.artifactId || null,
1130
+ fullPath: screenshotResult?.fullPath || null,
1131
+ };
1132
+ });
861
1133
  } catch (err) {
1134
+ if (isAbortError(err, options.signal)) throw err;
862
1135
  return { error: err.message };
863
1136
  }
864
1137
  }
865
1138
 
866
1139
  async typeText(text, options = {}) {
867
- const page = await this.ensurePage();
1140
+ const page = await this.ensurePage(options);
868
1141
 
869
1142
  try {
870
- for (const char of String(text || '')) {
871
- await page.keyboard.type(char, { delay: rand(45, 160) });
872
- }
1143
+ return await this._withPageCancellation(page, options.signal, async () => {
1144
+ const value = String(text ?? '');
1145
+ await page.keyboard.type(value, { delay: rand(45, 140) });
873
1146
 
874
- if (options.pressEnter) {
875
- await page.keyboard.press('Enter');
876
- await sleep(800);
877
- }
1147
+ if (options.pressEnter) {
1148
+ await page.keyboard.press('Enter');
1149
+ await sleep(800, options.signal);
1150
+ }
878
1151
 
879
- let screenshotResult = null;
880
- if (options.screenshot !== false) screenshotResult = await this.takeScreenshot();
1152
+ let screenshotResult = null;
1153
+ if (options.screenshot !== false) {
1154
+ screenshotResult = await this.takeScreenshot({ signal: options.signal });
1155
+ }
881
1156
 
882
- return {
883
- success: true,
884
- typed: String(text || ''),
885
- screenshotPath: screenshotResult?.screenshotPath || null,
886
- artifactId: screenshotResult?.artifactId || null,
887
- fullPath: screenshotResult?.fullPath || null
888
- };
1157
+ return {
1158
+ success: true,
1159
+ typed: value,
1160
+ screenshotPath: screenshotResult?.screenshotPath || null,
1161
+ artifactId: screenshotResult?.artifactId || null,
1162
+ fullPath: screenshotResult?.fullPath || null,
1163
+ };
1164
+ });
889
1165
  } catch (err) {
1166
+ if (isAbortError(err, options.signal)) throw err;
890
1167
  return { error: err.message };
891
1168
  }
892
1169
  }
893
1170
 
894
- async pressKey(key, screenshot = true) {
895
- const page = await this.ensurePage();
1171
+ async pressKey(key, screenshot = true, options = {}) {
1172
+ const page = await this.ensurePage(options);
896
1173
 
897
1174
  try {
898
- const normalized = String(key || '').trim();
899
- if (!normalized) {
900
- return { error: 'key required' };
901
- }
902
- await page.keyboard.press(normalized);
903
- await sleep(rand(250, 700));
904
-
905
- let screenshotResult = null;
906
- if (screenshot) screenshotResult = await this.takeScreenshot();
907
-
908
- return {
909
- success: true,
910
- key: normalized,
911
- screenshotPath: screenshotResult?.screenshotPath || null,
912
- artifactId: screenshotResult?.artifactId || null,
913
- fullPath: screenshotResult?.fullPath || null
914
- };
1175
+ return await this._withPageCancellation(page, options.signal, async () => {
1176
+ const normalized = String(key || '').trim();
1177
+ if (!normalized) {
1178
+ return { error: 'key required' };
1179
+ }
1180
+ await page.keyboard.press(normalized);
1181
+ await sleep(rand(250, 700), options.signal);
1182
+
1183
+ let screenshotResult = null;
1184
+ if (screenshot) screenshotResult = await this.takeScreenshot({ signal: options.signal });
1185
+
1186
+ return {
1187
+ success: true,
1188
+ key: normalized,
1189
+ screenshotPath: screenshotResult?.screenshotPath || null,
1190
+ artifactId: screenshotResult?.artifactId || null,
1191
+ fullPath: screenshotResult?.fullPath || null,
1192
+ };
1193
+ });
915
1194
  } catch (err) {
1195
+ if (isAbortError(err, options.signal)) throw err;
916
1196
  return { error: err.message };
917
1197
  }
918
1198
  }
919
1199
 
920
- async extract(selector, attribute, all = false) {
921
- const page = await this.ensurePage();
1200
+ async extract(selector, attribute, all = false, options = {}) {
1201
+ const page = await this.ensurePage(options);
922
1202
 
923
1203
  try {
924
- const rawHtml = await page.content().catch(() => '');
925
- const botDetection = await this._getBotDetection(page, rawHtml, '').catch(() => ({ detected: false, provider: null }));
926
- if (all) {
927
- const results = await page.$$eval(selector || 'body', (elements, attr) => {
928
- return elements.map(el => {
929
- if (attr === 'innerHTML') return el.innerHTML;
930
- if (attr === 'outerHTML') return el.outerHTML;
931
- if (attr) return el.getAttribute(attr) || '';
932
- return el.innerText || '';
933
- });
934
- }, attribute);
935
- return { results: results.slice(0, 100), botDetection };
936
- }
1204
+ return await this._withPageCancellation(page, options.signal, async () => {
1205
+ const rawHtml = await page.content().catch(() => '');
1206
+ const botDetection = await this._getBotDetection(page, rawHtml, '')
1207
+ .catch(() => ({ detected: false, provider: null }));
1208
+ if (all) {
1209
+ const results = await page.$$eval(selector || 'body', (elements, attr) => {
1210
+ return elements.slice(0, 100).map(el => {
1211
+ let value = '';
1212
+ if (attr === 'innerHTML') value = el.innerHTML;
1213
+ else if (attr === 'outerHTML') value = el.outerHTML;
1214
+ else if (attr) value = el.getAttribute(attr) || '';
1215
+ else value = el.innerText || '';
1216
+ return String(value).slice(0, 50000);
1217
+ });
1218
+ }, attribute);
1219
+ return { results, botDetection };
1220
+ }
937
1221
 
938
- const result = await page.$eval(selector || 'body', (el, attr) => {
939
- if (attr === 'innerHTML') return el.innerHTML;
940
- if (attr === 'outerHTML') return el.outerHTML;
941
- if (attr) return el.getAttribute(attr) || '';
942
- return el.innerText || '';
943
- }, attribute);
1222
+ const result = await page.$eval(selector || 'body', (el, attr) => {
1223
+ if (attr === 'innerHTML') return el.innerHTML;
1224
+ if (attr === 'outerHTML') return el.outerHTML;
1225
+ if (attr) return el.getAttribute(attr) || '';
1226
+ return el.innerText || '';
1227
+ }, attribute);
944
1228
 
945
- return { result: typeof result === 'string' ? result.slice(0, 50000) : result, botDetection };
1229
+ return { result: typeof result === 'string' ? result.slice(0, 50000) : result, botDetection };
1230
+ });
946
1231
  } catch (err) {
1232
+ if (isAbortError(err, options.signal)) throw err;
947
1233
  return { error: err.message };
948
1234
  }
949
1235
  }
950
1236
 
951
- async evaluate(script) {
952
- const page = await this.ensurePage();
1237
+ async evaluate(script, options = {}) {
1238
+ const source = String(script || '');
1239
+ if (!source) return { error: 'script required' };
1240
+ if (source.length > 10000) return { error: 'script exceeds maximum length (10000)' };
1241
+ const page = await this.ensurePage(options);
953
1242
  try {
954
- const result = await page.evaluate(buildIsolatedEvaluationExpression(script));
955
- return { result: typeof result === 'object' ? JSON.stringify(result) : String(result) };
1243
+ return await this._withPageCancellation(page, options.signal, async () => {
1244
+ const result = await page.evaluate(buildIsolatedEvaluationExpression(source));
1245
+ const serialized = typeof result === 'object' && result !== null
1246
+ ? JSON.stringify(result)
1247
+ : String(result);
1248
+ const output = serialized ?? 'undefined';
1249
+ const maxChars = 1024 * 1024;
1250
+ return {
1251
+ result: output.slice(0, maxChars),
1252
+ truncated: output.length > maxChars,
1253
+ };
1254
+ });
956
1255
  } catch (err) {
1256
+ if (isAbortError(err, options.signal)) throw err;
957
1257
  return { error: err.message };
958
1258
  }
959
1259
  }
@@ -963,14 +1263,12 @@ class BrowserController {
963
1263
  }
964
1264
 
965
1265
  async launch(options = {}) {
966
- void options;
967
- await this.ensureBrowser();
1266
+ await this.ensureBrowser(options);
968
1267
  return { success: true };
969
1268
  }
970
1269
 
971
1270
  isLaunched() {
972
- if (this.context) return true;
973
- return !!(this.browser && typeof this.browser.isConnected === 'function' && this.browser.isConnected());
1271
+ return this._contextIsOpen();
974
1272
  }
975
1273
 
976
1274
  getPageCount() {
@@ -981,84 +1279,244 @@ class BrowserController {
981
1279
  try { return this.browser.pages ? 1 : 0; } catch { return 0; }
982
1280
  }
983
1281
 
984
- async fill(selector, value) {
985
- return this.type(selector, String(value));
1282
+ async fill(selector, value, options = {}) {
1283
+ return this.type(selector, String(value), options);
986
1284
  }
987
1285
 
988
1286
  async extractContent(options = {}) {
989
- return this.extract(options.selector, options.attribute, options.all);
1287
+ return this.extract(options.selector, options.attribute, options.all, options);
990
1288
  }
991
1289
 
992
- async executeJS(code) {
993
- return this.evaluate(code);
1290
+ async executeJS(code, options = {}) {
1291
+ return this.evaluate(code, options);
994
1292
  }
995
1293
 
996
- async getPageInfo() {
1294
+ async getPageInfo(options = {}) {
1295
+ throwIfAborted(options.signal);
997
1296
  if (!this.page || this.page.isClosed()) return { url: null, title: null };
998
1297
  return {
999
1298
  url: this.page.url(),
1000
- title: await this.page.title()
1299
+ title: await raceWithSignal(this.page.title(), options.signal),
1300
+ protectedCredentialFill: Boolean(this._protectedCredentialFill),
1001
1301
  };
1002
1302
  }
1003
1303
 
1004
- async getCookies() {
1005
- await this.ensureBrowser();
1304
+ hasProtectedCredentialFill() {
1305
+ const fill = this._protectedCredentialFill;
1306
+ if (fill && fill.expiresAt <= Date.now() && !fill.clearing) {
1307
+ fill.clearing = true;
1308
+ Promise.allSettled([
1309
+ fill.usernameSelector ? fill.page?.locator(fill.usernameSelector).fill('') : null,
1310
+ fill.passwordSelector ? fill.page?.locator(fill.passwordSelector).fill('') : null,
1311
+ ]).finally(() => {
1312
+ if (this._protectedCredentialFill === fill) {
1313
+ this._protectedCredentialFill = null;
1314
+ }
1315
+ });
1316
+ }
1317
+ return Boolean(this._protectedCredentialFill);
1318
+ }
1319
+
1320
+ async fillCredential(input = {}, options = {}) {
1321
+ if (this.hasProtectedCredentialFill()) {
1322
+ throw new Error('A protected credential fill is already active.');
1323
+ }
1324
+ const page = await this.ensurePage(options);
1325
+ const allowedOrigin = new URL(String(input.allowedOrigin || '')).origin;
1326
+ if (new URL(page.url()).origin !== allowedOrigin) {
1327
+ throw new Error('The browser origin changed before credential fill.');
1328
+ }
1329
+ await page.reload({ waitUntil: 'domcontentloaded', timeout: 30_000 });
1330
+ if (new URL(page.url()).origin !== allowedOrigin) {
1331
+ throw new Error('The browser origin changed while preparing credential fill.');
1332
+ }
1333
+ const usernameSelector = String(input.usernameSelector || '').trim();
1334
+ const passwordSelector = String(input.passwordSelector || '').trim();
1335
+ if (!usernameSelector && !passwordSelector) throw new Error('At least one credential field selector is required.');
1336
+ const username = String(input.username || '');
1337
+ if (usernameSelector) {
1338
+ await page.waitForSelector(usernameSelector, { state: 'visible', timeout: 10_000 });
1339
+ await page.locator(usernameSelector).fill(username);
1340
+ }
1341
+ if (passwordSelector) {
1342
+ await page.waitForSelector(passwordSelector, { state: 'visible', timeout: 10_000 });
1343
+ await page.locator(passwordSelector).fill(String(input.password || ''));
1344
+ }
1345
+ const protectedFillId = crypto.randomUUID();
1346
+ this._protectedCredentialFill = {
1347
+ id: protectedFillId,
1348
+ page,
1349
+ allowedOrigin,
1350
+ usernameSelector,
1351
+ passwordSelector,
1352
+ submitSelector: passwordSelector || usernameSelector,
1353
+ expiresAt: Date.now() + 5 * 60 * 1000,
1354
+ };
1355
+ return { success: true, protectedFillId, origin: allowedOrigin };
1356
+ }
1357
+
1358
+ async submitProtectedCredential(protectedFillId, options = {}) {
1359
+ const fill = this._protectedCredentialFill;
1360
+ if (!this.hasProtectedCredentialFill() || fill?.id !== String(protectedFillId || '')) {
1361
+ throw new Error('Protected credential fill is missing or expired.');
1362
+ }
1363
+ const page = fill.page;
1364
+ if (!page || page.isClosed() || new URL(page.url()).origin !== fill.allowedOrigin) {
1365
+ this._protectedCredentialFill = null;
1366
+ throw new Error('The protected credential page changed before submission.');
1367
+ }
1368
+ try {
1369
+ await page.locator(fill.submitSelector).evaluate((input) => {
1370
+ const form = input.form;
1371
+ if (form && typeof form.requestSubmit === 'function') form.requestSubmit();
1372
+ else input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
1373
+ });
1374
+ await page.waitForLoadState('domcontentloaded', { timeout: 10_000 }).catch(() => {});
1375
+ return {
1376
+ success: true,
1377
+ url: page.url(),
1378
+ title: await page.title().catch(() => ''),
1379
+ protected: false,
1380
+ };
1381
+ } finally {
1382
+ if (fill.usernameSelector) {
1383
+ await page.locator(fill.usernameSelector).fill('').catch(() => {});
1384
+ }
1385
+ if (fill.passwordSelector) {
1386
+ await page.locator(fill.passwordSelector).fill('').catch(() => {});
1387
+ }
1388
+ this._protectedCredentialFill = null;
1389
+ }
1390
+ }
1391
+
1392
+ async cancelProtectedCredential(protectedFillId) {
1393
+ const fill = this._protectedCredentialFill;
1394
+ if (!fill || fill.id !== String(protectedFillId || '')) {
1395
+ throw new Error('Protected credential fill is missing or expired.');
1396
+ }
1397
+ if (fill.usernameSelector) {
1398
+ await fill.page?.locator(fill.usernameSelector).fill('').catch(() => {});
1399
+ }
1400
+ if (fill.passwordSelector) {
1401
+ await fill.page?.locator(fill.passwordSelector).fill('').catch(() => {});
1402
+ }
1403
+ this._protectedCredentialFill = null;
1404
+ return { success: true, protected: false };
1405
+ }
1406
+
1407
+ async getCookies(options = {}) {
1408
+ await this.ensureBrowser(options);
1006
1409
  if (!this.context || typeof this.context.cookies !== 'function') {
1007
1410
  return { cookies: [] };
1008
1411
  }
1009
- const cookies = await this.context.cookies().catch(() => []);
1412
+ const cookies = await raceWithSignal(this.context.cookies(), options.signal);
1010
1413
  return {
1011
1414
  cookies: Array.isArray(cookies) ? cookies : [],
1012
1415
  };
1013
1416
  }
1014
1417
 
1015
1418
  async close() {
1016
- if (this.page && !this.page.isClosed()) {
1017
- await this.page.close().catch(() => { });
1018
- }
1019
- if (this.context) {
1020
- await this.context.close().catch(() => { });
1021
- this.context = null;
1022
- this.browser = null;
1419
+ if (this._closePromise) return this._closePromise;
1420
+ this._closing = true;
1421
+ this._launchAbortController?.abort(new Error('Browser controller is closing.'));
1422
+ this._closePromise = (async () => {
1423
+ const launchPromise = this.launchPromise;
1424
+ if (launchPromise) await launchPromise.catch(() => {});
1425
+
1426
+ const page = this.page;
1427
+ const context = this.context;
1428
+ const browser = this.browser;
1023
1429
  this.page = null;
1024
- return;
1025
- }
1026
- if (this.browser) {
1027
- await this.browser.close().catch(() => { });
1430
+ this.context = null;
1028
1431
  this.browser = null;
1029
- this.page = null;
1432
+ this._protectedCredentialFill = null;
1433
+
1434
+ if (context) {
1435
+ await context.close({ reason: 'Browser controller closed' }).catch(() => {});
1436
+ } else if (browser) {
1437
+ await browser.close().catch(() => {});
1438
+ } else if (page && !page.isClosed()) {
1439
+ await page.close({ runBeforeUnload: false, reason: 'Browser controller closed' }).catch(() => {});
1440
+ }
1441
+ await this._stopVirtualDisplay();
1442
+ })();
1443
+ try {
1444
+ await this._closePromise;
1445
+ } finally {
1446
+ this._closePromise = null;
1447
+ this._closing = false;
1030
1448
  }
1031
1449
  }
1032
1450
 
1033
- async ensureVirtualDisplay() {
1451
+ async ensureVirtualDisplay(options = {}) {
1034
1452
  if (process.platform !== 'linux') {
1035
1453
  return;
1036
1454
  }
1037
- if (this.displayProcess && !this.displayProcess.killed) {
1455
+ if (this.displayProcess && this.displayProcess.exitCode == null) {
1038
1456
  return;
1039
1457
  }
1040
1458
  if (this.displayValue && String(this.displayValue).trim()) {
1041
1459
  return;
1042
1460
  }
1043
1461
 
1044
- const display = ':99';
1462
+ const display = chooseVirtualDisplay();
1045
1463
  const child = spawn('Xvfb', [display, '-screen', '0', '1440x900x24', '-ac', '-nolisten', 'tcp'], {
1046
1464
  stdio: ['ignore', 'ignore', 'pipe'],
1047
1465
  });
1466
+ this.displayProcess = child;
1048
1467
 
1049
1468
  let launchError = '';
1050
1469
  child.stderr.on('data', (chunk) => {
1051
- launchError += chunk.toString();
1470
+ if (launchError.length < 64 * 1024) launchError += chunk.toString();
1471
+ });
1472
+ child.once('close', () => {
1473
+ if (this.displayProcess === child) {
1474
+ this.displayProcess = null;
1475
+ if (this.displayValue === display) this.displayValue = process.env.DISPLAY || null;
1476
+ if (this._managedDisplayValue === display) this._managedDisplayValue = null;
1477
+ }
1052
1478
  });
1053
1479
 
1054
- await sleep(1000);
1055
- if (child.exitCode != null) {
1056
- throw new Error(`Failed to start Xvfb: ${String(launchError || `exit code ${child.exitCode}`).trim()}`);
1480
+ try {
1481
+ await Promise.race([
1482
+ sleep(1000, options.signal),
1483
+ new Promise((_, reject) => child.once('error', reject)),
1484
+ ]);
1485
+ if (child.exitCode != null) {
1486
+ throw new Error(`Failed to start Xvfb: ${String(launchError || `exit code ${child.exitCode}`).trim()}`);
1487
+ }
1488
+ } catch (error) {
1489
+ await this._stopVirtualDisplay();
1490
+ throw error;
1057
1491
  }
1058
1492
 
1059
- this.displayProcess = child;
1060
1493
  this.displayValue = display;
1494
+ this._managedDisplayValue = display;
1495
+ }
1496
+
1497
+ async _stopVirtualDisplay() {
1498
+ const child = this.displayProcess;
1499
+ const managedDisplay = this._managedDisplayValue;
1500
+ this.displayProcess = null;
1501
+ this._managedDisplayValue = null;
1502
+ if (managedDisplay && this.displayValue === managedDisplay) {
1503
+ this.displayValue = process.env.DISPLAY || null;
1504
+ }
1505
+ if (!child || child.exitCode != null) return;
1506
+
1507
+ const exited = new Promise((resolve) => child.once('close', resolve));
1508
+ try { child.kill('SIGTERM'); } catch {}
1509
+ await Promise.race([exited, sleep(1000)]);
1510
+ if (child.exitCode == null) {
1511
+ try { child.kill('SIGKILL'); } catch {}
1512
+ await Promise.race([exited, sleep(1000)]);
1513
+ }
1061
1514
  }
1062
1515
  }
1063
1516
 
1064
- module.exports = { BrowserController, resolveBrowserExecutablePath, buildIsolatedEvaluationExpression, normalizeWaitUntil };
1517
+ module.exports = {
1518
+ BrowserController,
1519
+ buildIsolatedEvaluationExpression,
1520
+ normalizeWaitUntil,
1521
+ resolveBrowserExecutablePath,
1522
+ };