@anvia/browser 1.0.7 → 1.0.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.
package/dist/index.js CHANGED
@@ -1,82 +1,680 @@
1
- // src/docker-browser.ts
1
+ // src/docker-browser-client.ts
2
2
  import { fileURLToPath } from "url";
3
3
 
4
- // src/connection.ts
5
- import { randomUUID } from "crypto";
6
- import { chromium } from "playwright-core";
4
+ // src/automation-client.ts
5
+ import { fork } from "child_process";
6
+
7
+ // src/automation-protocol.ts
8
+ function isAutomationResponse(value) {
9
+ if (!isRecord(value)) return false;
10
+ switch (value.kind) {
11
+ case "event":
12
+ return value.event === "disconnected";
13
+ case "cancelled":
14
+ return isRequestId(value.id);
15
+ case "response":
16
+ if (!isRequestId(value.id) || typeof value.ok !== "boolean") return false;
17
+ return value.ok ? Object.hasOwn(value, "value") : isSerializedError(value.error);
18
+ default:
19
+ return false;
20
+ }
21
+ }
22
+ function isSerializedError(value) {
23
+ return isRecord(value) && typeof value.name === "string" && typeof value.message === "string" && (value.stack === void 0 || typeof value.stack === "string") && (value.code === void 0 || typeof value.code === "string");
24
+ }
25
+ function isRequestId(value) {
26
+ return Number.isSafeInteger(value) && value > 0;
27
+ }
28
+ function isRecord(value) {
29
+ return typeof value === "object" && value !== null && !Array.isArray(value);
30
+ }
7
31
 
8
32
  // src/errors.ts
33
+ var defaults = {
34
+ action_timeout: { retryable: true, recovery: "retry" },
35
+ agent_action_busy: { retryable: true, recovery: "retry" },
36
+ cancelled: { retryable: true, recovery: "retry" },
37
+ connection_closed: { retryable: true, recovery: "reconnect" },
38
+ connection_timeout: { retryable: true, recovery: "reconnect" },
39
+ human_control_conflict: { retryable: true, recovery: "retry" },
40
+ human_controlled: { retryable: true, recovery: "retry" },
41
+ invalid_state: { retryable: false, recovery: "none" },
42
+ lifecycle_timeout: { retryable: true, recovery: "retry" },
43
+ navigation_blocked: { retryable: false, recovery: "none" },
44
+ not_ready: { retryable: true, recovery: "retry" },
45
+ readiness_timeout: { retryable: true, recovery: "retry" },
46
+ runtime_destroyed: { retryable: false, recovery: "recreate" },
47
+ startup_failed: { retryable: true, recovery: "recreate" },
48
+ tool_failed: { retryable: true, recovery: "retry" },
49
+ transport_failure: { retryable: true, recovery: "reconnect" }
50
+ };
9
51
  var BrowserError = class extends Error {
10
- constructor(message, code, options) {
52
+ constructor(message, code, options = {}) {
11
53
  super(message, options);
12
54
  this.code = code;
13
55
  this.name = "BrowserError";
56
+ this.retryable = options.retryable ?? defaults[code].retryable;
57
+ this.recovery = options.recovery ?? defaults[code].recovery;
58
+ this.capability = options.capability;
59
+ this.phase = options.phase;
14
60
  }
15
61
  code;
62
+ retryable;
63
+ recovery;
64
+ capability;
65
+ phase;
66
+ };
67
+ function cancellationError(reason, phase) {
68
+ return new BrowserError("Browser operation was cancelled by the caller.", "cancelled", {
69
+ cause: reason,
70
+ phase
71
+ });
72
+ }
73
+
74
+ // src/automation-client.ts
75
+ var cancellationCleanupTimeoutMs = 5e3;
76
+ var workerTerminationTimeoutMs = 3e3;
77
+ var maxWorkerStderrChars = 32768;
78
+ var AutomationWorkerClient = class _AutomationWorkerClient {
79
+ child;
80
+ pending = /* @__PURE__ */ new Map();
81
+ disconnectedListeners = /* @__PURE__ */ new Set();
82
+ nextId = 1;
83
+ isClosed = false;
84
+ disconnecting = false;
85
+ termination;
86
+ workerStderr = "";
87
+ onStderrData = (chunk) => {
88
+ this.workerStderr = `${this.workerStderr}${chunk}`.slice(-maxWorkerStderrChars);
89
+ };
90
+ onStderrError = () => {
91
+ };
92
+ onMessage = (message) => {
93
+ try {
94
+ this.handleMessage(message);
95
+ } catch (error) {
96
+ this.handleExit(
97
+ new BrowserError(
98
+ "Browser automation worker sent an invalid response.",
99
+ "transport_failure",
100
+ {
101
+ cause: error,
102
+ phase: "automation-worker"
103
+ }
104
+ )
105
+ );
106
+ void this.terminate().catch(() => void 0);
107
+ }
108
+ };
109
+ onError = (error) => {
110
+ this.handleExit(error);
111
+ void this.terminate().catch(() => void 0);
112
+ };
113
+ onExit = (code, signal) => {
114
+ const diagnostics = this.workerStderr.trim();
115
+ this.handleExit(
116
+ new Error(
117
+ `Browser automation worker exited: code=${code ?? "null"} signal=${signal ?? "null"}${diagnostics.length === 0 ? "" : `
118
+ ${diagnostics}`}`
119
+ )
120
+ );
121
+ };
122
+ constructor(factory) {
123
+ this.child = factory();
124
+ this.child.stderr?.setEncoding("utf8");
125
+ this.child.stderr?.on("data", this.onStderrData);
126
+ this.child.stderr?.on("error", this.onStderrError);
127
+ this.child.on("message", this.onMessage);
128
+ this.child.on("error", this.onError);
129
+ this.child.once("exit", this.onExit);
130
+ }
131
+ static async connect(options) {
132
+ assertPositiveSafeInteger(options.timeoutMs, "timeoutMs");
133
+ if (options.abortSignal?.aborted) {
134
+ throw cancellationError(options.abortSignal.reason, "connect");
135
+ }
136
+ let client;
137
+ try {
138
+ client = new _AutomationWorkerClient(options.workerFactory ?? spawnAutomationWorker);
139
+ } catch (error) {
140
+ throw new BrowserError(
141
+ "Unable to start the browser automation worker.",
142
+ "transport_failure",
143
+ {
144
+ cause: error,
145
+ phase: "connect",
146
+ recovery: "reconnect"
147
+ }
148
+ );
149
+ }
150
+ const timeoutController = new AbortController();
151
+ const timer = setTimeout(
152
+ () => timeoutController.abort(
153
+ new BrowserError(
154
+ "Browser connection did not initialize before the timeout.",
155
+ "connection_timeout",
156
+ { phase: "connect" }
157
+ )
158
+ ),
159
+ options.timeoutMs
160
+ );
161
+ timer.unref?.();
162
+ const abortSignal = options.abortSignal === void 0 ? timeoutController.signal : AbortSignal.any([options.abortSignal, timeoutController.signal]);
163
+ try {
164
+ await client.command(
165
+ {
166
+ method: "connect",
167
+ params: { endpointUrl: options.endpointUrl, timeoutMs: options.timeoutMs }
168
+ },
169
+ { abortSignal, phase: "connect" }
170
+ );
171
+ return client;
172
+ } catch (error) {
173
+ await client.terminate();
174
+ if (options.abortSignal?.aborted) {
175
+ throw cancellationError(options.abortSignal.reason, "connect");
176
+ }
177
+ if (timeoutController.signal.aborted) throw timeoutController.signal.reason;
178
+ if (error instanceof Error && (error.name === "TimeoutError" || /timeout/i.test(error.message))) {
179
+ throw new BrowserError(
180
+ "Browser connection did not initialize before the timeout.",
181
+ "connection_timeout",
182
+ { cause: error, phase: "connect" }
183
+ );
184
+ }
185
+ throw new BrowserError("Unable to initialize browser automation.", "transport_failure", {
186
+ cause: error,
187
+ phase: "connect"
188
+ });
189
+ } finally {
190
+ clearTimeout(timer);
191
+ }
192
+ }
193
+ get closed() {
194
+ return this.isClosed || this.child.exitCode !== null || this.child.signalCode !== null;
195
+ }
196
+ command(command, options) {
197
+ if (this.closed) {
198
+ return Promise.reject(
199
+ new BrowserError("Browser automation connection is closed.", "connection_closed", {
200
+ phase: options.phase
201
+ })
202
+ );
203
+ }
204
+ if (options.abortSignal?.aborted) {
205
+ return Promise.reject(this.abortError(options.abortSignal, options.phase));
206
+ }
207
+ const id = this.nextId++;
208
+ return new Promise((resolve, reject) => {
209
+ const pending = {
210
+ state: "active",
211
+ resolve: (value) => resolve(value),
212
+ reject
213
+ };
214
+ if (options.abortSignal !== void 0) {
215
+ pending.abortSignal = options.abortSignal;
216
+ pending.abort = () => this.cancelRequest(id, pending, options);
217
+ options.abortSignal.addEventListener("abort", pending.abort, { once: true });
218
+ }
219
+ this.pending.set(id, pending);
220
+ const request = { kind: "request", id, ...command };
221
+ try {
222
+ this.child.send(request, (error) => {
223
+ if (error == null) return;
224
+ this.rejectSend(options.phase, error);
225
+ });
226
+ } catch (error) {
227
+ this.rejectSend(options.phase, error);
228
+ }
229
+ });
230
+ }
231
+ async disconnect(options = {}) {
232
+ if (this.termination !== void 0) return this.termination;
233
+ this.disconnecting = true;
234
+ const timeoutMs = options.timeoutMs ?? 1e4;
235
+ const timeoutController = new AbortController();
236
+ const timer = setTimeout(
237
+ () => timeoutController.abort(
238
+ new BrowserError("Browser disconnect timed out.", "connection_timeout", {
239
+ phase: "disconnect"
240
+ })
241
+ ),
242
+ timeoutMs
243
+ );
244
+ timer.unref?.();
245
+ const abortSignal = options.abortSignal === void 0 ? timeoutController.signal : AbortSignal.any([options.abortSignal, timeoutController.signal]);
246
+ try {
247
+ if (!this.closed) {
248
+ await this.command(
249
+ { method: "disconnect", params: {} },
250
+ { abortSignal, phase: "disconnect" }
251
+ );
252
+ }
253
+ } catch (error) {
254
+ if (options.abortSignal?.aborted) {
255
+ throw cancellationError(options.abortSignal.reason, "disconnect");
256
+ }
257
+ if (timeoutController.signal.aborted) throw timeoutController.signal.reason;
258
+ if (!this.closed) throw error;
259
+ } finally {
260
+ clearTimeout(timer);
261
+ await this.terminate();
262
+ }
263
+ }
264
+ onDisconnected(listener) {
265
+ if (this.closed) {
266
+ queueMicrotask(() => {
267
+ try {
268
+ listener();
269
+ } catch {
270
+ }
271
+ });
272
+ return () => void 0;
273
+ }
274
+ this.disconnectedListeners.add(listener);
275
+ return () => this.disconnectedListeners.delete(listener);
276
+ }
277
+ async terminate() {
278
+ if (this.termination !== void 0) return this.termination;
279
+ this.termination = this.performTerminate();
280
+ return this.termination;
281
+ }
282
+ async performTerminate() {
283
+ this.isClosed = true;
284
+ if (this.child.exitCode !== null || this.child.signalCode !== null) return;
285
+ const exited = new Promise((resolve) => this.child.once("exit", () => resolve()));
286
+ this.child.kill("SIGTERM");
287
+ let forceTimer;
288
+ await Promise.race([
289
+ exited,
290
+ new Promise((resolve) => {
291
+ forceTimer = setTimeout(() => {
292
+ this.child.kill("SIGKILL");
293
+ resolve();
294
+ }, workerTerminationTimeoutMs);
295
+ forceTimer.unref?.();
296
+ })
297
+ ]);
298
+ if (forceTimer !== void 0) clearTimeout(forceTimer);
299
+ if (this.child.exitCode === null && this.child.signalCode === null) await exited;
300
+ }
301
+ cancelRequest(id, pending, options) {
302
+ if (pending.state !== "active") return;
303
+ pending.state = "cancelling";
304
+ pending.abortError = this.abortError(pending.abortSignal, options.phase);
305
+ if (options.cancelOperation === true && this.child.connected) {
306
+ try {
307
+ this.child.send({ kind: "cancel", id }, (error) => {
308
+ if (error !== null) void this.terminate().catch(() => void 0);
309
+ });
310
+ } catch {
311
+ void this.terminate().catch(() => void 0);
312
+ return;
313
+ }
314
+ pending.cleanupTimer = setTimeout(() => {
315
+ void this.terminate().catch(() => void 0);
316
+ }, cancellationCleanupTimeoutMs);
317
+ pending.cleanupTimer.unref?.();
318
+ return;
319
+ }
320
+ void this.terminate().catch(() => void 0);
321
+ }
322
+ abortError(abortSignal, phase) {
323
+ return abortSignal.reason instanceof BrowserError ? abortSignal.reason : cancellationError(abortSignal.reason, phase);
324
+ }
325
+ handleMessage(message) {
326
+ if (!isAutomationResponse(message)) {
327
+ throw new TypeError("Invalid browser automation worker response.");
328
+ }
329
+ if (message.kind === "event") {
330
+ if (!this.disconnecting) {
331
+ this.handleExit(
332
+ new BrowserError("Browser disconnected from CDP.", "connection_closed", {
333
+ phase: "automation-event"
334
+ })
335
+ );
336
+ void this.terminate().catch(() => void 0);
337
+ }
338
+ return;
339
+ }
340
+ const pending = this.pending.get(message.id);
341
+ if (pending === void 0) return;
342
+ if (message.kind === "cancelled") {
343
+ if (pending.state === "cancelling") {
344
+ this.settle(message.id, pending, () => pending.reject(pending.abortError));
345
+ }
346
+ return;
347
+ }
348
+ if (pending.state === "cancelling") return;
349
+ this.settle(message.id, pending, () => {
350
+ if (message.ok) pending.resolve(message.value);
351
+ else pending.reject(deserializeError(message.error));
352
+ });
353
+ }
354
+ handleExit(cause) {
355
+ if (this.isClosed && this.pending.size === 0) {
356
+ this.cleanupChildListeners();
357
+ return;
358
+ }
359
+ this.isClosed = true;
360
+ const error = cause instanceof BrowserError ? cause : new BrowserError("Browser automation worker terminated.", "transport_failure", {
361
+ cause,
362
+ phase: "automation-worker"
363
+ });
364
+ for (const [id, pending] of this.pending) {
365
+ this.settle(
366
+ id,
367
+ pending,
368
+ () => pending.reject(pending.state === "cancelling" ? pending.abortError : error)
369
+ );
370
+ }
371
+ const listeners = [...this.disconnectedListeners];
372
+ this.disconnectedListeners.clear();
373
+ for (const listener of listeners) {
374
+ try {
375
+ listener();
376
+ } catch {
377
+ }
378
+ }
379
+ this.cleanupChildListeners();
380
+ }
381
+ cleanupChildListeners() {
382
+ this.child.off("message", this.onMessage);
383
+ this.child.off("exit", this.onExit);
384
+ this.child.stderr?.off("data", this.onStderrData);
385
+ }
386
+ rejectSend(phase, cause) {
387
+ this.handleExit(
388
+ new BrowserError("Unable to send browser automation command.", "transport_failure", {
389
+ cause,
390
+ phase
391
+ })
392
+ );
393
+ void this.terminate().catch(() => void 0);
394
+ }
395
+ settle(id, pending, callback) {
396
+ if (this.pending.get(id) !== pending) return;
397
+ this.pending.delete(id);
398
+ if (pending.cleanupTimer !== void 0) clearTimeout(pending.cleanupTimer);
399
+ if (pending.abort !== void 0) {
400
+ pending.abortSignal?.removeEventListener("abort", pending.abort);
401
+ }
402
+ callback();
403
+ }
404
+ };
405
+ function spawnAutomationWorker() {
406
+ return fork(automationWorkerUrl(), [], {
407
+ execArgv: [],
408
+ serialization: "advanced",
409
+ stdio: ["ignore", "ignore", "pipe", "ipc"]
410
+ });
411
+ }
412
+ function automationWorkerUrl() {
413
+ return import.meta.url.endsWith("/src/automation-client.ts") ? new URL("../dist/automation-worker.js", import.meta.url) : new URL("./automation-worker.js", import.meta.url);
414
+ }
415
+ function deserializeError(value) {
416
+ const error = new Error(value.message);
417
+ error.name = value.name;
418
+ if (value.stack !== void 0) error.stack = value.stack;
419
+ if (value.code !== void 0) Object.assign(error, { code: value.code });
420
+ return error;
421
+ }
422
+ function assertPositiveSafeInteger(value, name) {
423
+ if (!Number.isSafeInteger(value) || value <= 0 || value > 2147483647) {
424
+ throw new RangeError(`${name} must be a positive integer no greater than 2147483647.`);
425
+ }
426
+ }
427
+
428
+ // src/scheduler.ts
429
+ var ResourceScheduler = class {
430
+ resources = /* @__PURE__ */ new Map();
431
+ readyResources = [];
432
+ activeTasks = /* @__PURE__ */ new Set();
433
+ serial;
434
+ maxConcurrentResources;
435
+ maxQueuedActions;
436
+ activeResources = 0;
437
+ queuedActions = 0;
438
+ closedError;
439
+ constructor(options) {
440
+ this.serial = options.mode === "serial";
441
+ this.maxConcurrentResources = options.maxConcurrentResources;
442
+ this.maxQueuedActions = options.maxQueuedActions;
443
+ }
444
+ run(resourceKey, abortSignal, operation) {
445
+ if (this.closedError !== void 0) return Promise.reject(this.closedError);
446
+ if (abortSignal?.aborted) {
447
+ return Promise.reject(abortReason(abortSignal, "agent-action-queue"));
448
+ }
449
+ if (this.queuedActions >= this.maxQueuedActions) {
450
+ return Promise.reject(
451
+ new BrowserError("Browser action queue capacity was reached.", "agent_action_busy", {
452
+ phase: "agent-action-queue"
453
+ })
454
+ );
455
+ }
456
+ const key = this.serial ? "browser" : resourceKey;
457
+ let resource = this.resources.get(key);
458
+ if (resource === void 0) {
459
+ resource = { running: false, ready: false, tasks: [] };
460
+ this.resources.set(key, resource);
461
+ }
462
+ return new Promise((resolve, reject) => {
463
+ const task = {
464
+ controller: new AbortController(),
465
+ operation,
466
+ resolve,
467
+ reject,
468
+ ...abortSignal === void 0 ? {} : { abortSignal }
469
+ };
470
+ if (abortSignal !== void 0) {
471
+ task.abort = () => {
472
+ const current = this.resources.get(key);
473
+ const index = current?.tasks.indexOf(task) ?? -1;
474
+ if (index === -1) return;
475
+ current?.tasks.splice(index, 1);
476
+ this.queuedActions -= 1;
477
+ abortSignal.removeEventListener("abort", task.abort);
478
+ reject(abortReason(abortSignal, "agent-action-queue"));
479
+ this.deleteIdleResource(key, current);
480
+ };
481
+ abortSignal.addEventListener("abort", task.abort, { once: true });
482
+ }
483
+ resource.tasks.push(task);
484
+ this.queuedActions += 1;
485
+ this.markReady(key, resource);
486
+ this.pump();
487
+ });
488
+ }
489
+ close(error = new BrowserError("Browser connection is closed.", "connection_closed")) {
490
+ if (this.closedError !== void 0) return;
491
+ this.closedError = error;
492
+ for (const task of this.activeTasks) task.controller.abort(error);
493
+ for (const [key, resource] of this.resources) {
494
+ for (const task of resource.tasks.splice(0)) {
495
+ this.queuedActions -= 1;
496
+ if (task.abort !== void 0) task.abortSignal?.removeEventListener("abort", task.abort);
497
+ task.reject(error);
498
+ }
499
+ resource.ready = false;
500
+ if (!resource.running) this.resources.delete(key);
501
+ }
502
+ this.readyResources.length = 0;
503
+ }
504
+ markReady(key, resource) {
505
+ if (resource.running || resource.ready || resource.tasks.length === 0) return;
506
+ resource.ready = true;
507
+ this.readyResources.push(key);
508
+ }
509
+ pump() {
510
+ while (this.closedError === void 0 && this.activeResources < this.maxConcurrentResources && this.readyResources.length > 0) {
511
+ const key = this.readyResources.shift();
512
+ if (key === void 0) return;
513
+ const resource = this.resources.get(key);
514
+ if (resource === void 0 || resource.running || resource.tasks.length === 0) continue;
515
+ resource.ready = false;
516
+ const task = resource.tasks.shift();
517
+ if (task === void 0) continue;
518
+ this.queuedActions -= 1;
519
+ if (task.abort !== void 0) task.abortSignal?.removeEventListener("abort", task.abort);
520
+ resource.running = true;
521
+ this.activeResources += 1;
522
+ this.activeTasks.add(task);
523
+ const effectiveSignal = task.abortSignal === void 0 ? task.controller.signal : AbortSignal.any([task.abortSignal, task.controller.signal]);
524
+ let operation;
525
+ try {
526
+ operation = task.operation(effectiveSignal);
527
+ } catch (error) {
528
+ operation = Promise.reject(error);
529
+ }
530
+ void operation.then(task.resolve, task.reject).finally(() => {
531
+ this.activeTasks.delete(task);
532
+ resource.running = false;
533
+ this.activeResources -= 1;
534
+ this.markReady(key, resource);
535
+ this.deleteIdleResource(key, resource);
536
+ this.pump();
537
+ });
538
+ }
539
+ }
540
+ deleteIdleResource(key, resource) {
541
+ if (resource !== void 0 && !resource.running && resource.tasks.length === 0) {
542
+ this.resources.delete(key);
543
+ }
544
+ }
545
+ };
546
+ var AsyncMutex = class {
547
+ waiters = [];
548
+ locked = false;
549
+ closedError;
550
+ async run(abortSignal, operation) {
551
+ const release = await this.acquire(abortSignal);
552
+ try {
553
+ return await operation();
554
+ } finally {
555
+ release();
556
+ }
557
+ }
558
+ close(error = new BrowserError("Browser connection is closed.", "connection_closed")) {
559
+ if (this.closedError !== void 0) return;
560
+ this.closedError = error;
561
+ for (const waiter of this.waiters.splice(0)) {
562
+ if (waiter.abort !== void 0) {
563
+ waiter.abortSignal?.removeEventListener("abort", waiter.abort);
564
+ }
565
+ waiter.reject(error);
566
+ }
567
+ }
568
+ acquire(abortSignal) {
569
+ if (this.closedError !== void 0) return Promise.reject(this.closedError);
570
+ if (abortSignal?.aborted) {
571
+ return Promise.reject(abortReason(abortSignal, "browser-resource-lock"));
572
+ }
573
+ if (!this.locked) {
574
+ this.locked = true;
575
+ return Promise.resolve(this.releaseCallback());
576
+ }
577
+ return new Promise((resolve, reject) => {
578
+ const waiter = {
579
+ resolve,
580
+ reject,
581
+ ...abortSignal === void 0 ? {} : { abortSignal }
582
+ };
583
+ if (abortSignal !== void 0) {
584
+ waiter.abort = () => {
585
+ const index = this.waiters.indexOf(waiter);
586
+ if (index === -1) return;
587
+ this.waiters.splice(index, 1);
588
+ reject(abortReason(abortSignal, "browser-resource-lock"));
589
+ };
590
+ abortSignal.addEventListener("abort", waiter.abort, { once: true });
591
+ }
592
+ this.waiters.push(waiter);
593
+ });
594
+ }
595
+ releaseCallback() {
596
+ let released = false;
597
+ return () => {
598
+ if (released) return;
599
+ released = true;
600
+ const waiter = this.waiters.shift();
601
+ if (waiter === void 0) {
602
+ this.locked = false;
603
+ return;
604
+ }
605
+ if (waiter.abort !== void 0) {
606
+ waiter.abortSignal?.removeEventListener("abort", waiter.abort);
607
+ }
608
+ waiter.resolve(this.releaseCallback());
609
+ };
610
+ }
16
611
  };
612
+ function abortReason(abortSignal, phase) {
613
+ return abortSignal.reason instanceof BrowserError ? abortSignal.reason : cancellationError(abortSignal.reason, phase);
614
+ }
17
615
 
18
616
  // src/connection.ts
617
+ var defaultConnectionTimeoutMs = 3e4;
618
+ var defaultActionTimeoutMs = 1e4;
619
+ var defaultMaxConcurrentTabs = 8;
620
+ var defaultMaxQueuedActions = 1e3;
621
+ var maxClosedTabTombstones = 1e4;
622
+ var maxTimerMs = 2147483647;
19
623
  async function connectPlaywrightBrowser(options) {
624
+ const timeoutMs = options.timeoutMs ?? defaultConnectionTimeoutMs;
625
+ assertPositiveSafeInteger2(timeoutMs, "timeoutMs");
20
626
  options.abortSignal?.throwIfAborted();
21
- const browser = await chromium.connectOverCDP(options.endpointUrl);
22
- if (options.abortSignal?.aborted) {
23
- await browser.close().catch(() => void 0);
24
- options.abortSignal.throwIfAborted();
25
- }
26
- return new PlaywrightBrowserConnectionImpl(browser, options.control);
627
+ const backend = await AutomationWorkerClient.connect({
628
+ endpointUrl: options.endpointUrl,
629
+ timeoutMs,
630
+ ...options.abortSignal === void 0 ? {} : { abortSignal: options.abortSignal },
631
+ ...options.workerFactory === void 0 ? {} : { workerFactory: options.workerFactory }
632
+ });
633
+ return new PlaywrightBrowserConnectionImpl({
634
+ backend,
635
+ control: options.control,
636
+ scheduling: options.scheduling,
637
+ ...options.onClosed === void 0 ? {} : { onClosed: options.onClosed }
638
+ });
27
639
  }
28
640
  var PlaywrightBrowserConnectionImpl = class {
29
- browser;
641
+ backend;
30
642
  control;
31
- tabIds = /* @__PURE__ */ new WeakMap();
32
- selected;
33
- isClosed = false;
34
- actionTail = Promise.resolve();
643
+ scheduler;
644
+ compatibilityMutex = new AsyncMutex();
645
+ contextMutex = new AsyncMutex();
646
+ closingTabs = /* @__PURE__ */ new Set();
647
+ closedTabs = /* @__PURE__ */ new Set();
35
648
  navigationPolicyKey;
36
649
  navigationGuard = Promise.resolve();
37
- constructor(browser, control) {
38
- this.browser = browser;
39
- this.control = control;
40
- this.selected = this.pages()[0];
41
- this.browser.once("disconnected", () => {
42
- this.isClosed = true;
43
- });
650
+ isClosed = false;
651
+ disconnectCompleted = false;
652
+ disconnectPromise;
653
+ removeDisconnectedListener;
654
+ onClosed;
655
+ constructor(options) {
656
+ this.backend = options.backend;
657
+ this.control = options.control;
658
+ this.onClosed = options.onClosed;
659
+ const scheduling = normalizeScheduling(options.scheduling);
660
+ this.scheduler = new ResourceScheduler(scheduling);
661
+ this.removeDisconnectedListener = this.backend.onDisconnected(() => this.markClosed());
44
662
  }
45
663
  get closed() {
46
- return this.isClosed || !this.browser.isConnected();
47
- }
48
- async listTabs() {
49
- return this.runAction(void 0, () => this.tabSummaries());
50
- }
51
- async tabSummaries() {
52
- return Object.freeze(
53
- await Promise.all(
54
- this.pages().map(
55
- async (page) => Object.freeze({
56
- id: this.idFor(page),
57
- title: await page.title(),
58
- url: page.url(),
59
- selected: page === this.selected
60
- })
61
- )
664
+ return this.isClosed || this.backend.closed;
665
+ }
666
+ async listTabs(options = {}) {
667
+ assertOptionsObject(options);
668
+ return this.runContextAction(
669
+ options.abortSignal,
670
+ options.timeoutMs ?? defaultActionTimeoutMs,
671
+ "list-tabs",
672
+ (signal) => this.backend.command(
673
+ { method: "listTabs", params: {} },
674
+ { abortSignal: signal, cancelOperation: true, phase: "list-tabs" }
62
675
  )
63
676
  );
64
677
  }
65
- async runAction(abortSignal, operation) {
66
- const previous = this.actionTail;
67
- let release;
68
- this.actionTail = new Promise((resolve) => {
69
- release = resolve;
70
- });
71
- await previous;
72
- try {
73
- this.assertOpen();
74
- await this.navigationGuard;
75
- return await this.control.runAgentAction(() => this.withAbort(abortSignal, operation));
76
- } finally {
77
- release?.();
78
- }
79
- }
80
678
  setNavigationPolicy(policy) {
81
679
  this.assertOpen();
82
680
  const key = JSON.stringify(policy);
@@ -87,141 +685,438 @@ var PlaywrightBrowserConnectionImpl = class {
87
685
  return;
88
686
  }
89
687
  this.navigationPolicyKey = key;
90
- const routeHandler = (route) => enforceNavigationPolicy(route, policy);
91
- this.navigationGuard = Promise.all(
92
- this.browser.contexts().map((context) => context.route("**/*", routeHandler))
93
- ).then(() => void 0);
688
+ const installation = this.withActionTimeout(
689
+ void 0,
690
+ defaultActionTimeoutMs,
691
+ "navigation-policy",
692
+ (signal) => this.scheduler.run(
693
+ "browser-context",
694
+ signal,
695
+ (scheduledSignal) => this.contextMutex.run(
696
+ scheduledSignal,
697
+ () => this.control.runAgentAction(async () => {
698
+ try {
699
+ await this.backend.command(
700
+ { method: "setNavigationPolicy", params: { policy } },
701
+ {
702
+ abortSignal: scheduledSignal,
703
+ cancelOperation: true,
704
+ phase: "navigation-policy"
705
+ }
706
+ );
707
+ } catch (error) {
708
+ throw normalizeAutomationError(error, "navigation-policy");
709
+ }
710
+ })
711
+ )
712
+ )
713
+ );
714
+ this.navigationGuard = installation.catch(async (error) => {
715
+ let cleanupError;
716
+ try {
717
+ await this.disconnect();
718
+ } catch (caught) {
719
+ cleanupError = caught;
720
+ }
721
+ throw new BrowserError(
722
+ "Unable to install the browser navigation policy.",
723
+ "transport_failure",
724
+ {
725
+ cause: combineCleanupError(error, cleanupError),
726
+ phase: "navigation-policy"
727
+ }
728
+ );
729
+ });
94
730
  void this.navigationGuard.catch(() => void 0);
95
731
  }
96
- selectedPage() {
97
- this.assertOpen();
98
- if (this.selected !== void 0 && !this.selected.isClosed()) return this.selected;
99
- const page = this.pages()[0];
100
- if (page === void 0) {
101
- throw new BrowserError("Browser has no open tabs.", "invalid_state");
102
- }
103
- this.selected = page;
104
- return page;
105
- }
106
- async openTab() {
107
- const context = this.browser.contexts()[0];
108
- if (context === void 0) {
109
- throw new BrowserError("Browser has no CDP context.", "invalid_state");
110
- }
111
- const page = await context.newPage();
112
- this.selected = page;
113
- this.idFor(page);
114
- return page;
115
- }
116
- selectTab(id) {
117
- const page = this.pageFor(id);
118
- this.selected = page;
119
- return page;
120
- }
121
- async closeTab(id) {
122
- const page = this.pageFor(id);
123
- await page.close();
124
- if (page === this.selected) this.selected = this.pages()[0];
125
- }
126
- idFor(page) {
127
- const existing = this.tabIds.get(page);
128
- if (existing !== void 0) return existing;
129
- const id = randomUUID();
130
- this.tabIds.set(page, id);
131
- return id;
132
- }
133
- async disconnect() {
134
- if (this.isClosed) return;
135
- this.isClosed = true;
136
- await this.browser.close().catch(() => void 0);
732
+ async openTab(abortSignal, timeoutMs) {
733
+ return this.withActionTimeout(
734
+ abortSignal,
735
+ timeoutMs,
736
+ "open-tab",
737
+ (signal) => this.compatibilityMutex.run(
738
+ signal,
739
+ () => this.runContextActionScheduled(
740
+ signal,
741
+ "open-tab",
742
+ (scheduledSignal) => this.backend.command(
743
+ { method: "openTab", params: {} },
744
+ { abortSignal: scheduledSignal, cancelOperation: true, phase: "open-tab" }
745
+ )
746
+ )
747
+ )
748
+ );
749
+ }
750
+ async selectTab(tabId, abortSignal, timeoutMs) {
751
+ this.assertTabUsable(tabId);
752
+ return this.withActionTimeout(
753
+ abortSignal,
754
+ timeoutMs,
755
+ "select-tab",
756
+ (signal) => this.compatibilityMutex.run(
757
+ signal,
758
+ () => this.runContextActionScheduled(
759
+ signal,
760
+ "select-tab",
761
+ (scheduledSignal) => this.backend.command(
762
+ { method: "selectTab", params: { tabId } },
763
+ { abortSignal: scheduledSignal, cancelOperation: true, phase: "select-tab" }
764
+ )
765
+ )
766
+ )
767
+ );
768
+ }
769
+ async closeTab(tabId, abortSignal, timeoutMs) {
770
+ this.assertTabUsable(tabId);
771
+ this.closingTabs.add(tabId);
772
+ let closed = false;
773
+ try {
774
+ const result = await this.withActionTimeout(
775
+ abortSignal,
776
+ timeoutMs,
777
+ "close-tab",
778
+ (signal) => this.scheduler.run(`tab:${tabId}`, signal, (scheduledSignal) => {
779
+ return this.contextMutex.run(
780
+ scheduledSignal,
781
+ () => this.control.runAgentAction(async () => {
782
+ try {
783
+ return await this.backend.command(
784
+ { method: "closeTab", params: { tabId } },
785
+ { abortSignal: scheduledSignal, cancelOperation: true, phase: "close-tab" }
786
+ );
787
+ } catch (error) {
788
+ throw normalizeAutomationError(error, "close-tab");
789
+ }
790
+ })
791
+ );
792
+ })
793
+ );
794
+ closed = true;
795
+ return result;
796
+ } finally {
797
+ this.closingTabs.delete(tabId);
798
+ if (closed && !this.isClosed) this.rememberClosedTab(tabId);
799
+ }
800
+ }
801
+ async runTabCommand(options) {
802
+ return this.withActionTimeout(
803
+ options.abortSignal,
804
+ options.timeoutMs,
805
+ options.phase,
806
+ async (signal) => {
807
+ if (options.tabId !== void 0) {
808
+ this.assertTabUsable(options.tabId);
809
+ return this.runScheduledTabCommand(options.tabId, options, signal);
810
+ }
811
+ return this.compatibilityMutex.run(signal, async () => {
812
+ const tabId = await this.selectedTabId(signal);
813
+ this.assertTabUsable(tabId);
814
+ return this.runScheduledTabCommand(tabId, options, signal);
815
+ });
816
+ }
817
+ );
818
+ }
819
+ async disconnect(options = {}) {
820
+ assertOptionsObject(options);
821
+ if (options.timeoutMs !== void 0) assertPositiveSafeInteger2(options.timeoutMs, "timeoutMs");
822
+ if (this.disconnectCompleted) return;
823
+ if (options.abortSignal?.aborted) {
824
+ throw cancellationError(options.abortSignal.reason, "disconnect");
825
+ }
826
+ if (this.disconnectPromise !== void 0) {
827
+ return waitForSharedDisconnect(this.disconnectPromise, options);
828
+ }
829
+ this.markClosed();
830
+ const promise = this.backend.disconnect(options);
831
+ this.disconnectPromise = promise;
832
+ try {
833
+ await promise;
834
+ this.disconnectCompleted = true;
835
+ } finally {
836
+ if (this.disconnectPromise === promise) this.disconnectPromise = void 0;
837
+ }
137
838
  }
138
839
  async [Symbol.asyncDispose]() {
139
840
  await this.disconnect();
140
841
  }
141
- pages() {
142
- return this.browser.contexts().flatMap((context) => context.pages());
842
+ async runScheduledTabCommand(tabId, options, abortSignal) {
843
+ return this.scheduler.run(
844
+ `tab:${tabId}`,
845
+ abortSignal,
846
+ (scheduledSignal) => this.control.runAgentAction(async () => {
847
+ await waitForOwnedPromise(this.navigationGuard, scheduledSignal);
848
+ try {
849
+ return await this.backend.command(options.command(tabId), {
850
+ abortSignal: scheduledSignal,
851
+ cancelOperation: true,
852
+ phase: options.phase
853
+ });
854
+ } catch (error) {
855
+ throw normalizeAutomationError(error, options.phase);
856
+ }
857
+ })
858
+ );
859
+ }
860
+ async selectedTabId(abortSignal) {
861
+ const tabs = await this.runContextActionScheduled(
862
+ abortSignal,
863
+ "selected-tab-lookup",
864
+ (signal) => this.backend.command(
865
+ { method: "listTabs", params: {} },
866
+ { abortSignal: signal, cancelOperation: true, phase: "selected-tab-lookup" }
867
+ )
868
+ );
869
+ const selected = tabs.find((tab) => tab.selected) ?? tabs[0];
870
+ if (selected === void 0) {
871
+ throw new BrowserError("Browser has no open tabs.", "invalid_state", {
872
+ phase: "selected-tab-lookup"
873
+ });
874
+ }
875
+ return selected.id;
876
+ }
877
+ runContextAction(abortSignal, timeoutMs, phase, operation) {
878
+ return this.withActionTimeout(
879
+ abortSignal,
880
+ timeoutMs,
881
+ phase,
882
+ (signal) => this.runContextActionScheduled(signal, phase, operation)
883
+ );
884
+ }
885
+ runContextActionScheduled(abortSignal, phase, operation) {
886
+ return this.scheduler.run(
887
+ "browser-context",
888
+ abortSignal,
889
+ (scheduledSignal) => this.contextMutex.run(
890
+ scheduledSignal,
891
+ () => this.control.runAgentAction(async () => {
892
+ this.assertOpen();
893
+ await waitForOwnedPromise(this.navigationGuard, scheduledSignal);
894
+ try {
895
+ return await operation(scheduledSignal);
896
+ } catch (error) {
897
+ throw normalizeAutomationError(error, phase);
898
+ }
899
+ })
900
+ )
901
+ );
902
+ }
903
+ async withActionTimeout(abortSignal, timeoutMs, phase, operation) {
904
+ assertPositiveSafeInteger2(timeoutMs, "timeoutMs");
905
+ if (abortSignal?.aborted) throw cancellationError(abortSignal.reason, phase);
906
+ const timeoutController = new AbortController();
907
+ const timer = setTimeout(
908
+ () => timeoutController.abort(
909
+ new BrowserError("Browser action timed out.", "action_timeout", { phase })
910
+ ),
911
+ timeoutMs
912
+ );
913
+ timer.unref?.();
914
+ const effectiveSignal = abortSignal === void 0 ? timeoutController.signal : AbortSignal.any([abortSignal, timeoutController.signal]);
915
+ try {
916
+ const value = await operation(effectiveSignal);
917
+ if (abortSignal?.aborted) throw cancellationError(abortSignal.reason, phase);
918
+ if (timeoutController.signal.aborted) throw timeoutController.signal.reason;
919
+ return value;
920
+ } catch (error) {
921
+ if (abortSignal?.aborted) throw cancellationError(abortSignal.reason, phase);
922
+ if (timeoutController.signal.aborted) throw timeoutController.signal.reason;
923
+ throw error;
924
+ } finally {
925
+ clearTimeout(timer);
926
+ }
143
927
  }
144
- pageFor(id) {
145
- const page = this.pages().find((candidate) => this.idFor(candidate) === id);
146
- if (page === void 0) {
147
- throw new BrowserError(`Browser tab does not exist: ${id}`, "invalid_state");
928
+ assertTabUsable(tabId) {
929
+ this.assertOpen();
930
+ if (this.closingTabs.has(tabId) || this.closedTabs.has(tabId)) {
931
+ throw new BrowserError(`Browser tab is closing or closed: ${tabId}`, "invalid_state", {
932
+ phase: "tab-lookup"
933
+ });
148
934
  }
149
- return page;
935
+ }
936
+ rememberClosedTab(tabId) {
937
+ this.closedTabs.add(tabId);
938
+ if (this.closedTabs.size <= maxClosedTabTombstones) return;
939
+ const oldest = this.closedTabs.values().next().value;
940
+ if (oldest !== void 0) this.closedTabs.delete(oldest);
150
941
  }
151
942
  assertOpen() {
152
- if (this.closed) throw new BrowserError("Browser connection is closed.", "connection_closed");
943
+ if (this.closed) {
944
+ throw new BrowserError("Browser connection is closed.", "connection_closed");
945
+ }
153
946
  }
154
- async withAbort(abortSignal, operation) {
155
- abortSignal?.throwIfAborted();
156
- if (abortSignal === void 0) return operation();
157
- return new Promise((resolve, reject) => {
158
- let settled = false;
159
- const finish = (callback) => {
160
- if (settled) return;
161
- settled = true;
162
- abortSignal.removeEventListener("abort", onAbort);
163
- callback();
164
- };
165
- const onAbort = () => {
166
- void this.disconnect().finally(() => finish(() => reject(abortSignal.reason)));
167
- };
168
- abortSignal.addEventListener("abort", onAbort, { once: true });
169
- void operation().then(
170
- (value) => finish(() => resolve(value)),
171
- (error) => finish(() => reject(error))
172
- );
173
- });
947
+ markClosed() {
948
+ if (this.isClosed) return;
949
+ this.isClosed = true;
950
+ const error = new BrowserError("Browser connection is closed.", "connection_closed");
951
+ this.scheduler.close(error);
952
+ this.compatibilityMutex.close(error);
953
+ this.contextMutex.close(error);
954
+ this.closingTabs.clear();
955
+ this.closedTabs.clear();
956
+ this.removeDisconnectedListener?.();
957
+ this.removeDisconnectedListener = void 0;
958
+ this.onClosed?.(this);
174
959
  }
175
960
  };
176
- async function enforceNavigationPolicy(route, policy) {
177
- const request = route.request();
178
- const frame = request.frame();
179
- if (request.isNavigationRequest() && frame === frame.page().mainFrame() && !isNavigationAllowed(request.url(), policy)) {
180
- await route.abort("blockedbyclient");
181
- return;
961
+ function normalizeScheduling(value) {
962
+ if (value === void 0) {
963
+ return {
964
+ mode: "serial",
965
+ maxConcurrentResources: 1,
966
+ maxQueuedActions: defaultMaxQueuedActions
967
+ };
182
968
  }
183
- await route.continue();
184
- }
185
- function isNavigationAllowed(value, policy) {
186
- let url;
187
- try {
188
- url = new URL(value);
189
- } catch {
190
- return false;
969
+ assertOptionsObject(value);
970
+ const maxQueuedActions = value.maxQueuedActions ?? defaultMaxQueuedActions;
971
+ assertPositiveSafeInteger2(maxQueuedActions, "scheduling.maxQueuedActions");
972
+ if (value.mode === "serial") {
973
+ return { mode: "serial", maxConcurrentResources: 1, maxQueuedActions };
191
974
  }
192
- if (url.protocol !== "http:" && url.protocol !== "https:") return false;
193
- if (url.username.length > 0 || url.password.length > 0) return false;
194
- return policy.mode === "allow-all-http" || policy.origins.includes(url.origin);
975
+ if (value.mode !== "per-tab") throw new TypeError("Unsupported browser scheduling mode.");
976
+ const maxConcurrentResources = value.maxConcurrentTabs ?? defaultMaxConcurrentTabs;
977
+ assertPositiveSafeInteger2(maxConcurrentResources, "scheduling.maxConcurrentTabs");
978
+ return { mode: "per-tab", maxConcurrentResources, maxQueuedActions };
195
979
  }
196
- function asConnection(connection) {
197
- if (!(connection instanceof PlaywrightBrowserConnectionImpl)) {
198
- throw new TypeError("connection must be created by DockerBrowser.connect().");
980
+ function normalizeAutomationError(error, phase) {
981
+ if (error instanceof BrowserError) return error;
982
+ if (error instanceof Error && /ERR_BLOCKED_BY_CLIENT|blockedbyclient/i.test(error.message)) {
983
+ return new BrowserError("Browser navigation was rejected by policy.", "navigation_blocked", {
984
+ cause: error,
985
+ phase
986
+ });
987
+ }
988
+ if (error instanceof Error && error.name === "TimeoutError") {
989
+ return new BrowserError("Browser action timed out.", "action_timeout", {
990
+ cause: error,
991
+ phase
992
+ });
993
+ }
994
+ if (error instanceof Error && /tab does not exist|has no open tabs|target page.*closed|page has been closed/i.test(
995
+ error.message
996
+ )) {
997
+ return new BrowserError(error.message, "invalid_state", { cause: error, phase });
998
+ }
999
+ if (error instanceof Error && /browser.*closed|disconnected|connection.*closed|websocket.*closed/i.test(error.message)) {
1000
+ return new BrowserError("Browser automation connection closed.", "connection_closed", {
1001
+ cause: error,
1002
+ phase
1003
+ });
1004
+ }
1005
+ return new BrowserError("Browser tool operation failed.", "tool_failed", {
1006
+ cause: error,
1007
+ phase
1008
+ });
1009
+ }
1010
+ function assertPositiveSafeInteger2(value, name) {
1011
+ if (!Number.isSafeInteger(value) || value <= 0 || value > maxTimerMs) {
1012
+ throw new RangeError(`${name} must be a positive integer no greater than ${maxTimerMs}.`);
1013
+ }
1014
+ }
1015
+ function assertOptionsObject(value) {
1016
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
1017
+ throw new TypeError("options must be an object.");
1018
+ }
1019
+ }
1020
+ function combineCleanupError(error, cleanupError) {
1021
+ return cleanupError === void 0 ? error : new AggregateError([error, cleanupError], "Browser automation cleanup also failed.");
1022
+ }
1023
+ async function waitForOwnedPromise(promise, abortSignal) {
1024
+ abortSignal.throwIfAborted();
1025
+ return new Promise((resolve, reject) => {
1026
+ let settled = false;
1027
+ const finish = (callback) => {
1028
+ if (settled) return;
1029
+ settled = true;
1030
+ abortSignal.removeEventListener("abort", abort);
1031
+ callback();
1032
+ };
1033
+ const abort = () => finish(() => reject(abortSignal.reason));
1034
+ abortSignal.addEventListener("abort", abort, { once: true });
1035
+ void promise.then(
1036
+ (value) => finish(() => resolve(value)),
1037
+ (error) => finish(() => reject(error))
1038
+ );
1039
+ });
1040
+ }
1041
+ async function waitForSharedDisconnect(promise, options) {
1042
+ const timeoutMs = options.timeoutMs ?? 1e4;
1043
+ assertPositiveSafeInteger2(timeoutMs, "timeoutMs");
1044
+ if (options.abortSignal?.aborted) {
1045
+ throw cancellationError(options.abortSignal.reason, "disconnect");
1046
+ }
1047
+ const controller = new AbortController();
1048
+ const timer = setTimeout(
1049
+ () => controller.abort(
1050
+ new BrowserError("Browser disconnect timed out.", "connection_timeout", {
1051
+ phase: "disconnect"
1052
+ })
1053
+ ),
1054
+ timeoutMs
1055
+ );
1056
+ timer.unref?.();
1057
+ const signal = options.abortSignal === void 0 ? controller.signal : AbortSignal.any([options.abortSignal, controller.signal]);
1058
+ try {
1059
+ await waitForOwnedPromise(promise, signal);
1060
+ } catch (error) {
1061
+ if (options.abortSignal?.aborted) {
1062
+ throw cancellationError(options.abortSignal.reason, "disconnect");
1063
+ }
1064
+ if (controller.signal.aborted) throw controller.signal.reason;
1065
+ throw error;
1066
+ } finally {
1067
+ clearTimeout(timer);
1068
+ }
1069
+ }
1070
+ function asConnection(connection) {
1071
+ if (!(connection instanceof PlaywrightBrowserConnectionImpl)) {
1072
+ throw new TypeError("connection must be created by DockerBrowser.connect().");
199
1073
  }
200
1074
  return connection;
201
1075
  }
202
1076
 
203
1077
  // src/control.ts
204
- import { randomUUID as randomUUID2 } from "crypto";
1078
+ import { randomUUID } from "crypto";
1079
+ var defaultAcquireTimeoutMs = 3e4;
1080
+ var maxTimerMs2 = 2147483647;
205
1081
  var BrowserControlState = class {
206
1082
  activeAgentActions = 0;
207
1083
  pendingAcquire;
208
1084
  activeLease;
209
1085
  humanPending = false;
210
1086
  destroyed = false;
1087
+ availability = "available";
211
1088
  snapshot() {
212
1089
  const lease = this.activeLease;
213
- return lease === void 0 ? Object.freeze({ mode: "agent" }) : Object.freeze({
214
- mode: "human",
215
- ownerId: lease.ownerId,
216
- expiresAt: lease.expiresAt
1090
+ const state = lease !== void 0 ? "human" : this.humanPending ? "human-pending" : this.activeAgentActions > 0 ? "agent-active" : "agent";
1091
+ return Object.freeze({
1092
+ mode: lease === void 0 ? "agent" : "human",
1093
+ state,
1094
+ availability: this.availability,
1095
+ activeAgentActions: this.activeAgentActions,
1096
+ humanPending: this.humanPending,
1097
+ ...lease === void 0 ? {} : {
1098
+ ownerId: lease.ownerId,
1099
+ expiresAt: lease.expiresAt
1100
+ }
217
1101
  });
218
1102
  }
219
1103
  async acquireHumanControl(options) {
220
1104
  validateAcquireOptions(options);
221
1105
  this.assertActive();
222
- options.abortSignal?.throwIfAborted();
223
- if (this.activeLease !== void 0 || this.humanPending) {
224
- throw new BrowserError("Browser human control is already acquired.", "human_controlled");
1106
+ if (options.abortSignal?.aborted) {
1107
+ throw cancellationError(options.abortSignal.reason, "human-control-acquire");
1108
+ }
1109
+ if (this.activeLease !== void 0) {
1110
+ throw new BrowserError("Browser human control is already acquired.", "human_controlled", {
1111
+ phase: "human-control-acquire"
1112
+ });
1113
+ }
1114
+ if (this.humanPending) {
1115
+ throw new BrowserError(
1116
+ "Browser human control acquisition is already pending.",
1117
+ "human_control_conflict",
1118
+ { phase: "human-control-acquire" }
1119
+ );
225
1120
  }
226
1121
  this.humanPending = true;
227
1122
  try {
@@ -232,16 +1127,30 @@ var BrowserControlState = class {
232
1127
  pending.abortSignal = options.abortSignal;
233
1128
  pending.abort = () => {
234
1129
  if (this.pendingAcquire !== pending) return;
235
- this.pendingAcquire = void 0;
236
- reject(options.abortSignal?.reason);
1130
+ this.clearPendingAcquire(pending);
1131
+ reject(cancellationError(options.abortSignal?.reason, "human-control-acquire"));
237
1132
  };
238
1133
  options.abortSignal.addEventListener("abort", pending.abort, { once: true });
239
1134
  }
1135
+ pending.timer = setTimeout(() => {
1136
+ if (this.pendingAcquire !== pending) return;
1137
+ this.clearPendingAcquire(pending);
1138
+ reject(
1139
+ new BrowserError(
1140
+ "Timed out waiting to acquire browser human control.",
1141
+ "human_control_conflict",
1142
+ { phase: "human-control-acquire" }
1143
+ )
1144
+ );
1145
+ }, options.timeoutMs ?? defaultAcquireTimeoutMs);
1146
+ pending.timer.unref?.();
240
1147
  this.pendingAcquire = pending;
241
1148
  });
242
1149
  }
243
1150
  this.assertActive();
244
- options.abortSignal?.throwIfAborted();
1151
+ if (options.abortSignal?.aborted) {
1152
+ throw cancellationError(options.abortSignal.reason, "human-control-acquire");
1153
+ }
245
1154
  const lease = new BrowserHumanControlLeaseImpl({
246
1155
  owner: this,
247
1156
  ownerId: options.ownerId,
@@ -252,118 +1161,785 @@ var BrowserControlState = class {
252
1161
  } finally {
253
1162
  this.humanPending = false;
254
1163
  }
255
- }
256
- async runAgentAction(operation) {
257
- this.assertActive();
258
- if (this.activeLease !== void 0 || this.humanPending) {
259
- throw new BrowserError("Browser is controlled by a human viewer.", "human_controlled");
1164
+ }
1165
+ async runAgentAction(operation) {
1166
+ this.assertActive();
1167
+ if (this.activeLease !== void 0) {
1168
+ throw new BrowserError("Browser is controlled by a human viewer.", "human_controlled", {
1169
+ phase: "agent-action"
1170
+ });
1171
+ }
1172
+ if (this.humanPending) {
1173
+ throw new BrowserError(
1174
+ "Browser human control acquisition is pending.",
1175
+ "human_control_conflict",
1176
+ { phase: "agent-action" }
1177
+ );
1178
+ }
1179
+ this.activeAgentActions += 1;
1180
+ try {
1181
+ return await operation();
1182
+ } finally {
1183
+ this.activeAgentActions -= 1;
1184
+ if (this.activeAgentActions === 0) this.resolvePendingAcquire();
1185
+ }
1186
+ }
1187
+ renewLease(lease, options) {
1188
+ if (this.activeLease !== lease) {
1189
+ throw new BrowserError("Browser human control lease is no longer active.", "invalid_state");
1190
+ }
1191
+ assertPositiveSafeInteger3(options.leaseTimeoutMs, "leaseTimeoutMs");
1192
+ lease.resetExpiration(options.leaseTimeoutMs);
1193
+ return this.snapshot();
1194
+ }
1195
+ releaseLease(lease) {
1196
+ if (this.activeLease !== lease) return;
1197
+ this.activeLease = void 0;
1198
+ lease.markReleased();
1199
+ }
1200
+ destroy() {
1201
+ if (this.destroyed) return;
1202
+ this.destroyed = true;
1203
+ this.activeLease?.release();
1204
+ if (this.pendingAcquire !== void 0) {
1205
+ const pending = this.pendingAcquire;
1206
+ this.pendingAcquire = void 0;
1207
+ this.clearPendingAcquire(pending);
1208
+ pending.reject(new BrowserError("Browser was destroyed.", "runtime_destroyed"));
1209
+ }
1210
+ this.humanPending = false;
1211
+ this.availability = "destroyed";
1212
+ }
1213
+ setAvailability(availability) {
1214
+ if (this.destroyed) return;
1215
+ if (availability === "destroyed") {
1216
+ this.destroy();
1217
+ return;
1218
+ }
1219
+ this.availability = availability;
1220
+ if (availability !== "disconnected") return;
1221
+ this.activeLease?.release();
1222
+ if (this.pendingAcquire !== void 0) {
1223
+ const pending = this.pendingAcquire;
1224
+ this.clearPendingAcquire(pending);
1225
+ pending.reject(
1226
+ new BrowserError("Browser runtime is disconnected.", "connection_closed", {
1227
+ phase: "human-control-acquire"
1228
+ })
1229
+ );
1230
+ }
1231
+ this.humanPending = false;
1232
+ }
1233
+ assertActive() {
1234
+ if (this.destroyed) throw new BrowserError("Browser was destroyed.", "runtime_destroyed");
1235
+ if (this.availability === "disconnected") {
1236
+ throw new BrowserError("Browser runtime is disconnected.", "connection_closed");
1237
+ }
1238
+ }
1239
+ resolvePendingAcquire() {
1240
+ const pending = this.pendingAcquire;
1241
+ if (pending === void 0) return;
1242
+ this.clearPendingAcquire(pending);
1243
+ pending.resolve();
1244
+ }
1245
+ clearPendingAcquire(pending) {
1246
+ if (this.pendingAcquire === pending) this.pendingAcquire = void 0;
1247
+ if (pending.timer !== void 0) clearTimeout(pending.timer);
1248
+ if (pending.abort !== void 0) {
1249
+ pending.abortSignal?.removeEventListener("abort", pending.abort);
1250
+ }
1251
+ }
1252
+ };
1253
+ var BrowserHumanControlLeaseImpl = class {
1254
+ id = randomUUID();
1255
+ ownerId;
1256
+ owner;
1257
+ expiration = 0;
1258
+ timer;
1259
+ released = false;
1260
+ constructor(options) {
1261
+ this.owner = options.owner;
1262
+ this.ownerId = options.ownerId;
1263
+ this.resetExpiration(options.leaseTimeoutMs);
1264
+ }
1265
+ get expiresAt() {
1266
+ return new Date(this.expiration).toISOString();
1267
+ }
1268
+ renew(options) {
1269
+ if (this.released) {
1270
+ throw new BrowserError("Browser human control lease is no longer active.", "invalid_state");
1271
+ }
1272
+ return this.owner.renewLease(this, options);
1273
+ }
1274
+ release() {
1275
+ this.owner.releaseLease(this);
1276
+ }
1277
+ markReleased() {
1278
+ if (this.released) return;
1279
+ this.released = true;
1280
+ if (this.timer !== void 0) clearTimeout(this.timer);
1281
+ this.timer = void 0;
1282
+ }
1283
+ resetExpiration(leaseTimeoutMs) {
1284
+ if (this.timer !== void 0) clearTimeout(this.timer);
1285
+ this.expiration = Date.now() + leaseTimeoutMs;
1286
+ this.timer = setTimeout(() => this.release(), leaseTimeoutMs);
1287
+ this.timer.unref?.();
1288
+ }
1289
+ async [Symbol.asyncDispose]() {
1290
+ this.release();
1291
+ }
1292
+ };
1293
+ function validateAcquireOptions(options) {
1294
+ if (!isRecord2(options)) throw new TypeError("options must be an object.");
1295
+ if (typeof options.ownerId !== "string" || options.ownerId.length === 0) {
1296
+ throw new TypeError("ownerId must be a non-empty string.");
1297
+ }
1298
+ assertPositiveSafeInteger3(options.leaseTimeoutMs, "leaseTimeoutMs");
1299
+ if (options.timeoutMs !== void 0) assertPositiveSafeInteger3(options.timeoutMs, "timeoutMs");
1300
+ }
1301
+ function assertPositiveSafeInteger3(value, name) {
1302
+ if (!Number.isSafeInteger(value) || value <= 0 || value > maxTimerMs2) {
1303
+ throw new RangeError(`${name} must be a positive integer no greater than ${maxTimerMs2}.`);
1304
+ }
1305
+ }
1306
+ function isRecord2(value) {
1307
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1308
+ }
1309
+
1310
+ // src/lifecycle.ts
1311
+ var defaultLifecycleTimeoutMs = 12e4;
1312
+ var maxTimerMs3 = 2147483647;
1313
+ function boundedSignal(options, defaultTimeoutMs, phase) {
1314
+ const timeoutMs = options.timeoutMs ?? defaultTimeoutMs;
1315
+ assertPositiveSafeInteger4(timeoutMs, "timeoutMs");
1316
+ if (options.abortSignal?.aborted) throw cancellationError(options.abortSignal.reason, phase);
1317
+ const controller = new AbortController();
1318
+ const timer = setTimeout(
1319
+ () => controller.abort(
1320
+ new BrowserError(`Browser lifecycle operation timed out: ${phase}.`, "lifecycle_timeout", {
1321
+ phase
1322
+ })
1323
+ ),
1324
+ timeoutMs
1325
+ );
1326
+ timer.unref?.();
1327
+ const signal = options.abortSignal === void 0 ? controller.signal : AbortSignal.any([options.abortSignal, controller.signal]);
1328
+ return {
1329
+ signal,
1330
+ timedOut: () => controller.signal.aborted,
1331
+ throwIfAborted: () => {
1332
+ if (options.abortSignal?.aborted) {
1333
+ throw cancellationError(options.abortSignal.reason, phase);
1334
+ }
1335
+ if (controller.signal.aborted) throw controller.signal.reason;
1336
+ },
1337
+ normalize: (error, message, code) => {
1338
+ if (options.abortSignal?.aborted) {
1339
+ return error instanceof AggregateError ? new BrowserError("Browser operation was cancelled by the caller.", "cancelled", {
1340
+ cause: error,
1341
+ phase
1342
+ }) : cancellationError(options.abortSignal.reason, phase);
1343
+ }
1344
+ if (controller.signal.aborted) {
1345
+ const reason = controller.signal.reason;
1346
+ return error === reason ? reason : new BrowserError(reason.message, "lifecycle_timeout", { cause: error, phase });
1347
+ }
1348
+ if (error instanceof BrowserError) return error;
1349
+ return new BrowserError(message, code, { cause: error, phase });
1350
+ },
1351
+ dispose: () => clearTimeout(timer)
1352
+ };
1353
+ }
1354
+ function validateLifecycleOptions(options, phase) {
1355
+ if (options.timeoutMs !== void 0) assertPositiveSafeInteger4(options.timeoutMs, "timeoutMs");
1356
+ if (options.abortSignal?.aborted) {
1357
+ throw cancellationError(options.abortSignal.reason, phase);
1358
+ }
1359
+ }
1360
+ async function waitForSharedLifecycle(promise, options, phase) {
1361
+ const bounded = boundedSignal(options, defaultLifecycleTimeoutMs, phase);
1362
+ try {
1363
+ await waitForPromise(promise, bounded.signal);
1364
+ bounded.throwIfAborted();
1365
+ } catch (error) {
1366
+ throw bounded.normalize(error, `Unable to wait for ${phase}.`, "transport_failure");
1367
+ } finally {
1368
+ bounded.dispose();
1369
+ }
1370
+ }
1371
+ function assertPositiveSafeInteger4(value, name) {
1372
+ if (!Number.isSafeInteger(value) || value <= 0 || value > maxTimerMs3) {
1373
+ throw new RangeError(`${name} must be a positive integer no greater than ${maxTimerMs3}.`);
1374
+ }
1375
+ }
1376
+ function assertOptionsObject2(value) {
1377
+ if (!isRecord3(value)) throw new TypeError("options must be an object.");
1378
+ }
1379
+ function isRecord3(value) {
1380
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1381
+ }
1382
+ async function waitForPromise(promise, abortSignal) {
1383
+ abortSignal.throwIfAborted();
1384
+ return new Promise((resolve, reject) => {
1385
+ let settled = false;
1386
+ const finish = (callback) => {
1387
+ if (settled) return;
1388
+ settled = true;
1389
+ abortSignal.removeEventListener("abort", abort);
1390
+ callback();
1391
+ };
1392
+ const abort = () => finish(() => reject(abortSignal.reason));
1393
+ abortSignal.addEventListener("abort", abort, { once: true });
1394
+ void promise.then(
1395
+ (value) => finish(() => resolve(value)),
1396
+ (error) => finish(() => reject(error))
1397
+ );
1398
+ });
1399
+ }
1400
+
1401
+ // src/readiness.ts
1402
+ var cdpPort = 9222;
1403
+ var noVncPort = 6080;
1404
+ var browserCapabilities = ["runtime", "browser", "automation", "desktop"];
1405
+ var automationProbeAttemptTimeoutMs = 5e3;
1406
+ async function probeBrowserCapability(options) {
1407
+ const { capability, sandbox, abortSignal, deadline } = options;
1408
+ switch (capability) {
1409
+ case "runtime":
1410
+ if (sandbox.state !== "running") {
1411
+ throw new BrowserError(`Browser is not running: ${sandbox.state}`, "invalid_state");
1412
+ }
1413
+ return;
1414
+ case "browser":
1415
+ await sandbox.runtime.waitForPort({
1416
+ containerPort: cdpPort,
1417
+ timeoutMs: remainingTime(deadline),
1418
+ abortSignal
1419
+ });
1420
+ await assertCdpHttpReady(`${endpointFor(sandbox, cdpPort)}/json/version`, abortSignal);
1421
+ return;
1422
+ case "desktop":
1423
+ await sandbox.runtime.waitForPort({
1424
+ containerPort: noVncPort,
1425
+ timeoutMs: remainingTime(deadline),
1426
+ abortSignal
1427
+ });
1428
+ await assertHttpReady(`${endpointFor(sandbox, noVncPort)}/vnc.html`, abortSignal);
1429
+ return;
1430
+ case "automation":
1431
+ await sandbox.runtime.waitForPort({
1432
+ containerPort: cdpPort,
1433
+ timeoutMs: remainingTime(deadline),
1434
+ abortSignal
1435
+ });
1436
+ await assertCdpHttpReady(`${endpointFor(sandbox, cdpPort)}/json/version`, abortSignal);
1437
+ for (let attempt = 0; ; attempt += 1) {
1438
+ abortSignal.throwIfAborted();
1439
+ try {
1440
+ const connection = await options.connectBrowser({
1441
+ endpointUrl: endpointFor(sandbox, cdpPort),
1442
+ control: options.control,
1443
+ timeoutMs: Math.min(automationProbeAttemptTimeoutMs, remainingTime(deadline)),
1444
+ abortSignal
1445
+ });
1446
+ try {
1447
+ if (connection.closed) {
1448
+ throw new Error("CDP connection closed during initialization.");
1449
+ }
1450
+ } finally {
1451
+ await connection.disconnect({
1452
+ timeoutMs: remainingTime(deadline),
1453
+ abortSignal
1454
+ });
1455
+ }
1456
+ abortSignal.throwIfAborted();
1457
+ return;
1458
+ } catch (error) {
1459
+ if (abortSignal.aborted) throw abortSignal.reason ?? error;
1460
+ await waitForRetry(abortSignal, Math.min(500, 50 * 2 ** Math.min(attempt, 3)));
1461
+ }
1462
+ }
1463
+ }
1464
+ }
1465
+ function endpointFor(sandbox, containerPort) {
1466
+ const published = publishedPort(sandbox, containerPort);
1467
+ return `http://${hostForUrl(published.host)}:${published.hostPort}`;
1468
+ }
1469
+ function normalizeReadinessError(error, capability, state) {
1470
+ if (state.callerAborted) {
1471
+ return new BrowserError("Browser readiness was cancelled by the caller.", "cancelled", {
1472
+ cause: error,
1473
+ capability,
1474
+ phase: "readiness"
1475
+ });
1476
+ }
1477
+ if (state.timedOut) {
1478
+ return new BrowserError(
1479
+ `Browser ${capability} capability did not become ready before the timeout.`,
1480
+ "readiness_timeout",
1481
+ { cause: error, capability, phase: "readiness" }
1482
+ );
1483
+ }
1484
+ if (error instanceof BrowserError && (error.code === "connection_closed" || error.code === "runtime_destroyed")) {
1485
+ return error;
1486
+ }
1487
+ return new BrowserError(`Browser ${capability} capability is not ready.`, "not_ready", {
1488
+ cause: error,
1489
+ capability,
1490
+ phase: "readiness"
1491
+ });
1492
+ }
1493
+ async function assertCdpHttpReady(url, abortSignal) {
1494
+ await assertHttpReady(url, abortSignal, async (response) => {
1495
+ const value = await response.json();
1496
+ if (!isRecord4(value) || typeof value.webSocketDebuggerUrl !== "string" || !value.webSocketDebuggerUrl.startsWith("ws")) {
1497
+ throw new Error("CDP version endpoint did not expose a WebSocket debugger URL.");
1498
+ }
1499
+ });
1500
+ }
1501
+ async function assertHttpReady(url, abortSignal, verify) {
1502
+ while (true) {
1503
+ abortSignal.throwIfAborted();
1504
+ try {
1505
+ const response = await fetch(url, { signal: abortSignal, redirect: "error" });
1506
+ try {
1507
+ if (response.ok) {
1508
+ await verify?.(response);
1509
+ return;
1510
+ }
1511
+ } finally {
1512
+ await response.body?.cancel().catch(() => void 0);
1513
+ }
1514
+ } catch (error) {
1515
+ if (abortSignal.aborted) throw abortSignal.reason ?? error;
1516
+ }
1517
+ await waitForRetry(abortSignal);
1518
+ }
1519
+ }
1520
+ async function waitForRetry(abortSignal, delayMs = 50) {
1521
+ await new Promise((resolve, reject) => {
1522
+ const timer = setTimeout(() => finish(), delayMs);
1523
+ timer.unref?.();
1524
+ const abort = () => finish(abortSignal.reason ?? new DOMException("Aborted", "AbortError"));
1525
+ abortSignal.addEventListener("abort", abort, { once: true });
1526
+ function finish(error) {
1527
+ clearTimeout(timer);
1528
+ abortSignal.removeEventListener("abort", abort);
1529
+ if (error === void 0) resolve();
1530
+ else reject(error);
1531
+ }
1532
+ });
1533
+ }
1534
+ function remainingTime(deadline) {
1535
+ return Math.max(1, deadline - Date.now());
1536
+ }
1537
+ function publishedPort(sandbox, containerPort) {
1538
+ const port = sandbox.runtime.publishedPorts.find(
1539
+ (candidate) => candidate.containerPort === containerPort && candidate.protocol === "tcp"
1540
+ );
1541
+ if (port === void 0) {
1542
+ throw new BrowserError(`Browser port is not published: ${containerPort}`, "invalid_state");
1543
+ }
1544
+ return port;
1545
+ }
1546
+ function hostForUrl(host) {
1547
+ return host.includes(":") ? `[${host}]` : host;
1548
+ }
1549
+ function isRecord4(value) {
1550
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1551
+ }
1552
+
1553
+ // src/docker-browser.ts
1554
+ var defaultConnectTimeoutMs = 3e4;
1555
+ var capabilities = browserCapabilities;
1556
+ var DockerBrowserHandle = class {
1557
+ id;
1558
+ sandbox;
1559
+ desktop;
1560
+ control = new BrowserControlState();
1561
+ connections = /* @__PURE__ */ new Set();
1562
+ ownedOperations = /* @__PURE__ */ new Set();
1563
+ capabilityStates = new Map(
1564
+ capabilities.map((capability) => [capability, Object.freeze({ capability, state: "unknown" })])
1565
+ );
1566
+ capabilityGenerations = new Map(
1567
+ capabilities.map((capability) => [capability, 0])
1568
+ );
1569
+ handleState = "active";
1570
+ connectionPending = false;
1571
+ stopPromise;
1572
+ destroyPromise;
1573
+ connectBrowser;
1574
+ constructor(sandbox, connectBrowser = connectPlaywrightBrowser) {
1575
+ this.sandbox = sandbox;
1576
+ this.id = sandbox.id;
1577
+ this.connectBrowser = connectBrowser;
1578
+ this.desktop = Object.freeze({
1579
+ protocol: "novnc",
1580
+ containerPort: noVncPort,
1581
+ control: this.control
1582
+ });
1583
+ }
1584
+ get state() {
1585
+ return this.handleState === "active" ? this.sandbox.state : this.handleState;
1586
+ }
1587
+ inspector(options) {
1588
+ return this.sandbox.inspector(options);
1589
+ }
1590
+ readiness() {
1591
+ const terminal = this.handleState === "destroyed" || this.handleState === "destroying" ? "destroyed" : this.handleState === "stopped" || this.handleState === "stopping" ? "stopped" : void 0;
1592
+ const entries = Object.fromEntries(
1593
+ capabilities.map((capability) => {
1594
+ const current = this.capabilityStates.get(capability);
1595
+ return [
1596
+ capability,
1597
+ terminal === void 0 ? current : Object.freeze({ capability, state: terminal })
1598
+ ];
1599
+ })
1600
+ );
1601
+ const values = Object.values(entries);
1602
+ const state = this.handleState === "error" ? "failed" : terminal ?? (values.some((value) => value.state === "checking") ? "checking" : values.every((value) => value.state === "unknown") ? "unknown" : values.every((value) => value.state === "ready") ? "ready" : values.some((value) => value.state === "failed") ? values.some((value) => value.state === "ready") ? "degraded" : "failed" : "partial");
1603
+ return Object.freeze({ state, capabilities: Object.freeze(entries) });
1604
+ }
1605
+ async waitForCapabilities(options) {
1606
+ validateReadinessOptions(options);
1607
+ this.assertRunning();
1608
+ if (options.abortSignal?.aborted) {
1609
+ throw cancellationError(options.abortSignal.reason, "readiness");
1610
+ }
1611
+ const requested = options.capabilities ?? capabilities;
1612
+ const ownedController = new AbortController();
1613
+ const timeoutController = new AbortController();
1614
+ const deadline = Date.now() + options.timeoutMs;
1615
+ const timer = setTimeout(
1616
+ () => timeoutController.abort(
1617
+ new BrowserError("Browser readiness timed out.", "readiness_timeout", {
1618
+ phase: "readiness"
1619
+ })
1620
+ ),
1621
+ options.timeoutMs
1622
+ );
1623
+ timer.unref?.();
1624
+ const signals = [ownedController.signal, timeoutController.signal];
1625
+ if (options.abortSignal !== void 0) signals.push(options.abortSignal);
1626
+ const abortSignal = AbortSignal.any(signals);
1627
+ const previous = new Map(
1628
+ requested.map((capability) => [capability, this.capabilityStates.get(capability)])
1629
+ );
1630
+ const generations = new Map(
1631
+ requested.map((capability) => {
1632
+ const generation = (this.capabilityGenerations.get(capability) ?? 0) + 1;
1633
+ this.capabilityGenerations.set(capability, generation);
1634
+ return [capability, generation];
1635
+ })
1636
+ );
1637
+ for (const capability of requested) this.setCapability(capability, "checking");
1638
+ const promise = Promise.allSettled(
1639
+ requested.map(async (capability) => {
1640
+ try {
1641
+ await probeBrowserCapability({
1642
+ capability,
1643
+ sandbox: this.sandbox,
1644
+ control: this.control,
1645
+ connectBrowser: this.connectBrowser,
1646
+ abortSignal,
1647
+ deadline
1648
+ });
1649
+ abortSignal.throwIfAborted();
1650
+ if (this.capabilityGenerations.get(capability) === generations.get(capability)) {
1651
+ this.setCapability(capability, "ready");
1652
+ }
1653
+ } catch (error) {
1654
+ const normalized = normalizeReadinessError(error, capability, {
1655
+ callerAborted: options.abortSignal?.aborted === true,
1656
+ timedOut: timeoutController.signal.aborted
1657
+ });
1658
+ if (this.capabilityGenerations.get(capability) === generations.get(capability)) {
1659
+ if (options.abortSignal?.aborted) {
1660
+ this.capabilityStates.set(capability, previous.get(capability));
1661
+ } else {
1662
+ this.setCapability(capability, "failed", normalized);
1663
+ }
1664
+ }
1665
+ throw normalized;
1666
+ }
1667
+ })
1668
+ );
1669
+ const owned = { controller: ownedController, promise };
1670
+ this.ownedOperations.add(owned);
1671
+ try {
1672
+ const results = await promise;
1673
+ if (options.abortSignal?.aborted) {
1674
+ throw cancellationError(options.abortSignal.reason, "readiness");
1675
+ }
1676
+ const failures = results.filter(
1677
+ (result) => result.status === "rejected"
1678
+ );
1679
+ this.updateControlAvailability();
1680
+ if (failures.length > 0) throw failures[0].reason;
1681
+ return this.readiness();
1682
+ } finally {
1683
+ clearTimeout(timer);
1684
+ this.ownedOperations.delete(owned);
1685
+ }
1686
+ }
1687
+ async waitUntilReady(options) {
1688
+ await this.waitForCapabilities(options);
1689
+ }
1690
+ async connect(options = {}) {
1691
+ assertOptionsObject2(options);
1692
+ if (options.timeoutMs !== void 0) assertPositiveSafeInteger4(options.timeoutMs, "timeoutMs");
1693
+ this.assertRunning();
1694
+ if (options.abortSignal?.aborted) {
1695
+ throw cancellationError(options.abortSignal.reason, "connect");
1696
+ }
1697
+ if (this.connectionPending || this.connections.size > 0) {
1698
+ throw new BrowserError(
1699
+ "Browser already has an active or pending automation connection.",
1700
+ "agent_action_busy",
1701
+ { phase: "connect" }
1702
+ );
1703
+ }
1704
+ this.connectionPending = true;
1705
+ const controller = new AbortController();
1706
+ const signals = [controller.signal];
1707
+ if (options.abortSignal !== void 0) signals.push(options.abortSignal);
1708
+ const abortSignal = AbortSignal.any(signals);
1709
+ let owned;
1710
+ const promise = (async () => {
1711
+ await Promise.resolve();
1712
+ try {
1713
+ abortSignal.throwIfAborted();
1714
+ const connection = await this.connectBrowser({
1715
+ endpointUrl: endpointFor(this.sandbox, cdpPort),
1716
+ control: this.control,
1717
+ timeoutMs: options.timeoutMs ?? defaultConnectTimeoutMs,
1718
+ abortSignal,
1719
+ scheduling: options.scheduling,
1720
+ onClosed: (closedConnection) => this.connections.delete(closedConnection)
1721
+ });
1722
+ this.connections.add(connection);
1723
+ const disconnectedDuringInitialization = connection.closed;
1724
+ if (disconnectedDuringInitialization || this.handleState !== "active" || this.state !== "running") {
1725
+ this.connections.delete(connection);
1726
+ let cleanupError;
1727
+ try {
1728
+ await connection.disconnect();
1729
+ } catch (caught) {
1730
+ cleanupError = caught;
1731
+ }
1732
+ if (disconnectedDuringInitialization && this.handleState === "active" && this.state === "running") {
1733
+ throw new BrowserError(
1734
+ "Browser disconnected while automation was initializing.",
1735
+ "connection_closed",
1736
+ {
1737
+ ...cleanupError === void 0 ? {} : { cause: cleanupError },
1738
+ phase: "connect"
1739
+ }
1740
+ );
1741
+ }
1742
+ const lifecycleError = this.lifecycleError(
1743
+ "Browser stopped while the connection was initializing."
1744
+ );
1745
+ throw cleanupError === void 0 ? lifecycleError : new BrowserError(lifecycleError.message, lifecycleError.code, {
1746
+ cause: cleanupError,
1747
+ ...lifecycleError.phase === void 0 ? {} : { phase: lifecycleError.phase }
1748
+ });
1749
+ }
1750
+ return connection;
1751
+ } catch (error) {
1752
+ if (options.abortSignal?.aborted) {
1753
+ throw cancellationError(options.abortSignal.reason, "connect");
1754
+ }
1755
+ if (controller.signal.aborted) throw controller.signal.reason;
1756
+ if (error instanceof BrowserError) throw error;
1757
+ throw new BrowserError("Unable to connect to Chromium over CDP.", "transport_failure", {
1758
+ cause: error,
1759
+ phase: "connect"
1760
+ });
1761
+ } finally {
1762
+ this.connectionPending = false;
1763
+ this.ownedOperations.delete(owned);
1764
+ }
1765
+ })();
1766
+ owned = { controller, promise };
1767
+ this.ownedOperations.add(owned);
1768
+ return promise;
1769
+ }
1770
+ async stop(options = {}) {
1771
+ assertOptionsObject2(options);
1772
+ if (this.handleState === "stopped") return;
1773
+ if (this.handleState === "destroyed" || this.handleState === "destroying") {
1774
+ throw new BrowserError("Browser runtime is destroyed.", "runtime_destroyed");
1775
+ }
1776
+ validateLifecycleOptions(options, "stop-browser");
1777
+ if (this.stopPromise !== void 0) {
1778
+ return waitForSharedLifecycle(this.stopPromise, options, "stop-browser");
260
1779
  }
261
- this.activeAgentActions += 1;
1780
+ this.handleState = "stopping";
1781
+ this.control.setAvailability("disconnected");
1782
+ const promise = this.performStop(options);
1783
+ this.stopPromise = promise;
262
1784
  try {
263
- return await operation();
1785
+ await promise;
264
1786
  } finally {
265
- this.activeAgentActions -= 1;
266
- if (this.activeAgentActions === 0) this.resolvePendingAcquire();
1787
+ if (this.stopPromise === promise) this.stopPromise = void 0;
267
1788
  }
268
1789
  }
269
- renewLease(lease, options) {
270
- if (this.activeLease !== lease) {
271
- throw new BrowserError("Browser human control lease is no longer active.", "invalid_state");
1790
+ async destroy(options = {}) {
1791
+ assertOptionsObject2(options);
1792
+ if (this.handleState === "destroyed") return;
1793
+ validateLifecycleOptions(options, "destroy-browser");
1794
+ if (this.destroyPromise === void 0) {
1795
+ this.handleState = "destroying";
1796
+ this.control.destroy();
1797
+ const activeStop = this.stopPromise;
1798
+ const promise = (async () => {
1799
+ if (activeStop !== void 0) await activeStop.catch(() => void 0);
1800
+ await this.performDestroy();
1801
+ })();
1802
+ this.destroyPromise = promise;
1803
+ void promise.then(
1804
+ () => {
1805
+ if (this.destroyPromise === promise) this.destroyPromise = void 0;
1806
+ },
1807
+ () => {
1808
+ if (this.destroyPromise === promise) this.destroyPromise = void 0;
1809
+ }
1810
+ );
272
1811
  }
273
- assertPositiveSafeInteger(options.leaseTimeoutMs, "leaseTimeoutMs");
274
- lease.resetExpiration(options.leaseTimeoutMs);
275
- return this.snapshot();
1812
+ return waitForSharedLifecycle(this.destroyPromise, options, "destroy-browser");
276
1813
  }
277
- releaseLease(lease) {
278
- if (this.activeLease !== lease) return;
279
- this.activeLease = void 0;
280
- lease.markReleased();
1814
+ async [Symbol.asyncDispose]() {
1815
+ await this.destroy();
281
1816
  }
282
- destroy() {
283
- if (this.destroyed) return;
284
- this.destroyed = true;
285
- this.activeLease?.release();
286
- if (this.pendingAcquire !== void 0) {
287
- const pending = this.pendingAcquire;
288
- this.pendingAcquire = void 0;
289
- if (pending.abort !== void 0) {
290
- pending.abortSignal?.removeEventListener("abort", pending.abort);
1817
+ async performStop(options) {
1818
+ const bounded = boundedSignal(options, defaultLifecycleTimeoutMs, "stop-browser");
1819
+ try {
1820
+ await this.abortOwnedOperations(
1821
+ new BrowserError("Browser runtime is stopping.", "connection_closed", {
1822
+ phase: "stop-browser"
1823
+ })
1824
+ );
1825
+ const disconnectFailures = await this.disconnectAll({ abortSignal: bounded.signal });
1826
+ await this.sandbox.stop({ abortSignal: bounded.signal });
1827
+ if (this.handleState === "stopping") this.handleState = "stopped";
1828
+ bounded.throwIfAborted();
1829
+ if (disconnectFailures.length > 0) {
1830
+ throw new BrowserError(
1831
+ "Browser stopped, but one or more automation workers failed to disconnect.",
1832
+ "transport_failure",
1833
+ {
1834
+ cause: new AggregateError(disconnectFailures, "Automation worker cleanup failed."),
1835
+ phase: "stop-browser",
1836
+ recovery: "restart"
1837
+ }
1838
+ );
291
1839
  }
292
- pending.reject(new BrowserError("Browser was destroyed.", "invalid_state"));
1840
+ } catch (error) {
1841
+ if (this.handleState === "stopping") this.handleState = "error";
1842
+ throw bounded.normalize(error, "Unable to stop browser runtime.", "transport_failure");
1843
+ } finally {
1844
+ bounded.dispose();
293
1845
  }
294
1846
  }
295
- assertActive() {
296
- if (this.destroyed) throw new BrowserError("Browser was destroyed.", "invalid_state");
297
- }
298
- resolvePendingAcquire() {
299
- const pending = this.pendingAcquire;
300
- if (pending === void 0) return;
301
- this.pendingAcquire = void 0;
302
- if (pending.abort !== void 0) {
303
- pending.abortSignal?.removeEventListener("abort", pending.abort);
1847
+ async performDestroy() {
1848
+ try {
1849
+ await this.abortOwnedOperations(
1850
+ new BrowserError("Browser runtime was destroyed.", "runtime_destroyed", {
1851
+ phase: "destroy-browser"
1852
+ })
1853
+ );
1854
+ const disconnectFailures = await this.disconnectAll({});
1855
+ await this.sandbox.destroy();
1856
+ this.handleState = "destroyed";
1857
+ if (disconnectFailures.length > 0) {
1858
+ throw new BrowserError(
1859
+ "Browser was destroyed, but one or more automation workers failed to disconnect.",
1860
+ "transport_failure",
1861
+ {
1862
+ cause: new AggregateError(disconnectFailures, "Automation worker cleanup failed."),
1863
+ phase: "destroy-browser",
1864
+ retryable: false,
1865
+ recovery: "none"
1866
+ }
1867
+ );
1868
+ }
1869
+ } catch (error) {
1870
+ if (this.handleState !== "destroyed") this.handleState = "error";
1871
+ if (error instanceof BrowserError) throw error;
1872
+ throw new BrowserError("Unable to destroy browser runtime.", "transport_failure", {
1873
+ cause: error,
1874
+ phase: "destroy-browser",
1875
+ recovery: "retry"
1876
+ });
304
1877
  }
305
- pending.resolve();
306
1878
  }
307
- };
308
- var BrowserHumanControlLeaseImpl = class {
309
- id = randomUUID2();
310
- ownerId;
311
- owner;
312
- expiration = 0;
313
- timer;
314
- released = false;
315
- constructor(options) {
316
- this.owner = options.owner;
317
- this.ownerId = options.ownerId;
318
- this.resetExpiration(options.leaseTimeoutMs);
319
- }
320
- get expiresAt() {
321
- return new Date(this.expiration).toISOString();
322
- }
323
- renew(options) {
324
- if (this.released) {
325
- throw new BrowserError("Browser human control lease is no longer active.", "invalid_state");
1879
+ assertRunning() {
1880
+ if (this.handleState === "destroyed" || this.handleState === "destroying") {
1881
+ throw new BrowserError("Browser runtime is destroyed.", "runtime_destroyed");
1882
+ }
1883
+ if (this.handleState !== "active" || this.state !== "running") {
1884
+ throw new BrowserError(`Browser is not running: ${this.state}`, "invalid_state");
326
1885
  }
327
- return this.owner.renewLease(this, options);
328
1886
  }
329
- release() {
330
- this.owner.releaseLease(this);
1887
+ lifecycleError(message) {
1888
+ return this.handleState === "destroying" || this.handleState === "destroyed" ? new BrowserError(message, "runtime_destroyed") : new BrowserError(message, "connection_closed", { phase: "connect" });
331
1889
  }
332
- markReleased() {
333
- if (this.released) return;
334
- this.released = true;
335
- if (this.timer !== void 0) clearTimeout(this.timer);
336
- this.timer = void 0;
1890
+ async abortOwnedOperations(error) {
1891
+ const operations = [...this.ownedOperations];
1892
+ for (const operation of operations) operation.controller.abort(error);
1893
+ await Promise.allSettled(operations.map((operation) => operation.promise));
337
1894
  }
338
- resetExpiration(leaseTimeoutMs) {
339
- if (this.timer !== void 0) clearTimeout(this.timer);
340
- this.expiration = Date.now() + leaseTimeoutMs;
341
- this.timer = setTimeout(() => this.release(), leaseTimeoutMs);
342
- this.timer.unref?.();
1895
+ async disconnectAll(options) {
1896
+ const connections = [...this.connections];
1897
+ this.connections.clear();
1898
+ const results = await Promise.allSettled(
1899
+ connections.map((connection) => connection.disconnect(options))
1900
+ );
1901
+ return results.filter((result) => result.status === "rejected").map((result) => result.reason);
1902
+ }
1903
+ setCapability(capability, state, error) {
1904
+ this.capabilityStates.set(
1905
+ capability,
1906
+ Object.freeze({
1907
+ capability,
1908
+ state,
1909
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
1910
+ ...error === void 0 ? {} : { error }
1911
+ })
1912
+ );
343
1913
  }
344
- async [Symbol.asyncDispose]() {
345
- this.release();
1914
+ updateControlAvailability() {
1915
+ if (this.handleState !== "active" || this.state !== "running") {
1916
+ this.control.setAvailability("disconnected");
1917
+ return;
1918
+ }
1919
+ const failed = capabilities.some(
1920
+ (capability) => this.capabilityStates.get(capability)?.state === "failed"
1921
+ );
1922
+ this.control.setAvailability(failed ? "degraded" : "available");
346
1923
  }
347
1924
  };
348
- function validateAcquireOptions(options) {
349
- if (!isRecord(options)) throw new TypeError("options must be an object.");
350
- if (typeof options.ownerId !== "string" || options.ownerId.length === 0) {
351
- throw new TypeError("ownerId must be a non-empty string.");
352
- }
353
- assertPositiveSafeInteger(options.leaseTimeoutMs, "leaseTimeoutMs");
354
- }
355
- function assertPositiveSafeInteger(value, name) {
356
- if (!Number.isSafeInteger(value) || value <= 0) {
357
- throw new RangeError(`${name} must be a positive safe integer.`);
1925
+ function validateReadinessOptions(options) {
1926
+ assertOptionsObject2(options);
1927
+ assertPositiveSafeInteger4(options.timeoutMs, "timeoutMs");
1928
+ if (options.capabilities === void 0) return;
1929
+ if (!Array.isArray(options.capabilities) || options.capabilities.length === 0) {
1930
+ throw new TypeError("capabilities must be a non-empty array.");
1931
+ }
1932
+ const unique = /* @__PURE__ */ new Set();
1933
+ for (const capability of options.capabilities) {
1934
+ if (!capabilities.includes(capability)) {
1935
+ throw new TypeError(`Unsupported browser capability: ${capability}`);
1936
+ }
1937
+ if (unique.has(capability)) throw new TypeError(`Duplicate browser capability: ${capability}`);
1938
+ unique.add(capability);
358
1939
  }
359
1940
  }
360
- function isRecord(value) {
361
- return typeof value === "object" && value !== null && !Array.isArray(value);
362
- }
363
1941
 
364
- // src/docker-browser.ts
365
- var cdpPort = 9222;
366
- var noVncPort = 6080;
1942
+ // src/docker-browser-client.ts
367
1943
  var browserSchema = "1";
368
1944
  var defaultSharedMemoryMb = 1024;
369
1945
  var seccompProfilePath = fileURLToPath(
@@ -373,7 +1949,7 @@ var DockerBrowserClient = class {
373
1949
  sandboxClient;
374
1950
  image;
375
1951
  constructor(options) {
376
- if (!isRecord2(options)) throw new TypeError("options must be an object.");
1952
+ if (!isRecord3(options)) throw new TypeError("options must be an object.");
377
1953
  if (!isSandboxClient(options.sandboxClient)) {
378
1954
  throw new TypeError("sandboxClient must be a DockerSandboxClient.");
379
1955
  }
@@ -382,16 +1958,20 @@ var DockerBrowserClient = class {
382
1958
  this.image = options.image;
383
1959
  }
384
1960
  async pullImage(options = {}) {
385
- assertOptionsObject(options);
386
- let pullOptions = { image: this.image };
387
- if (options.abortSignal !== void 0) {
388
- pullOptions = { ...pullOptions, abortSignal: options.abortSignal };
1961
+ assertOptionsObject2(options);
1962
+ const bounded = boundedSignal(options, defaultLifecycleTimeoutMs, "pull-image");
1963
+ try {
1964
+ await this.sandboxClient.pullImage({ image: this.image, abortSignal: bounded.signal });
1965
+ bounded.throwIfAborted();
1966
+ } catch (error) {
1967
+ throw bounded.normalize(error, "Unable to pull the browser image.", "startup_failed");
1968
+ } finally {
1969
+ bounded.dispose();
389
1970
  }
390
- await this.sandboxClient.pullImage(pullOptions);
391
1971
  }
392
1972
  async createBrowser(options) {
393
1973
  validateCreateOptions(options);
394
- options.abortSignal?.throwIfAborted();
1974
+ const bounded = boundedSignal(options, defaultLifecycleTimeoutMs, "create-browser");
395
1975
  const resources = {
396
1976
  ...options.resources,
397
1977
  sharedMemoryMb: options.resources?.sharedMemoryMb ?? defaultSharedMemoryMb
@@ -402,252 +1982,122 @@ var DockerBrowserClient = class {
402
1982
  workspace: options.workspace,
403
1983
  network: { mode: "bridge", ports: [cdpPort, noVncPort] },
404
1984
  user: "pwuser",
405
- labels: {
406
- "anvia.browser.schema": browserSchema
407
- },
1985
+ labels: { "anvia.browser.schema": browserSchema },
408
1986
  resources,
409
1987
  security: {
410
1988
  noNewPrivileges: true,
411
1989
  dropCapabilities: ["ALL"],
412
1990
  addCapabilities: ["SYS_CHROOT"],
413
1991
  seccompProfile: { type: "path", path: seccompProfilePath }
414
- }
1992
+ },
1993
+ abortSignal: bounded.signal
415
1994
  };
416
1995
  if (options.id !== void 0) sandboxOptions = { ...sandboxOptions, id: options.id };
417
1996
  if (options.runtime !== void 0) {
418
1997
  sandboxOptions = { ...sandboxOptions, runtime: options.runtime };
419
1998
  }
420
- if (options.abortSignal !== void 0) {
421
- sandboxOptions = { ...sandboxOptions, abortSignal: options.abortSignal };
422
- }
423
- const sandbox = await this.sandboxClient.createSandbox(sandboxOptions);
1999
+ let sandbox;
424
2000
  try {
425
- await configureBrowser(sandbox, options);
426
- await startBrowserServices(sandbox, options.abortSignal);
2001
+ sandbox = await this.sandboxClient.createSandbox(sandboxOptions);
2002
+ await configureBrowser(sandbox, options, bounded.signal);
2003
+ await startBrowserServices(sandbox, bounded.signal);
2004
+ bounded.throwIfAborted();
427
2005
  return new DockerBrowserHandle(sandbox);
428
2006
  } catch (error) {
429
- await sandbox.destroy().catch(() => void 0);
430
- throw new BrowserError("Unable to configure the browser sandbox.", "startup_failed", {
431
- cause: error
432
- });
2007
+ let cleanupError;
2008
+ try {
2009
+ await sandbox?.destroy();
2010
+ } catch (caught) {
2011
+ cleanupError = caught;
2012
+ }
2013
+ throw bounded.normalize(
2014
+ combineCleanupError2(error, cleanupError, "Browser sandbox cleanup also failed."),
2015
+ "Unable to configure the browser sandbox.",
2016
+ "startup_failed"
2017
+ );
2018
+ } finally {
2019
+ bounded.dispose();
433
2020
  }
434
2021
  }
435
2022
  async resumeBrowser(options) {
436
- assertOptionsObject(options);
2023
+ assertOptionsObject2(options);
437
2024
  assertNonEmptyString(options.id, "id");
438
- options.abortSignal?.throwIfAborted();
439
- const sandbox = await this.sandboxClient.resumeSandbox(options);
2025
+ const bounded = boundedSignal(options, defaultLifecycleTimeoutMs, "resume-browser");
2026
+ let sandbox;
440
2027
  try {
441
- await assertBrowserImage(sandbox, options.abortSignal);
442
- await startBrowserServices(sandbox, options.abortSignal);
443
- return new DockerBrowserHandle(sandbox);
444
- } catch (error) {
445
- await sandbox.stop().catch(() => void 0);
446
- throw new BrowserError("Unable to resume browser services.", "startup_failed", {
447
- cause: error
2028
+ sandbox = await this.sandboxClient.resumeSandbox({
2029
+ id: options.id,
2030
+ abortSignal: bounded.signal
448
2031
  });
449
- }
450
- }
451
- };
452
- var DockerBrowserHandle = class {
453
- id;
454
- sandbox;
455
- desktop;
456
- control = new BrowserControlState();
457
- connections = /* @__PURE__ */ new Set();
458
- constructor(sandbox) {
459
- this.sandbox = sandbox;
460
- this.id = sandbox.id;
461
- this.desktop = Object.freeze({
462
- protocol: "novnc",
463
- containerPort: noVncPort,
464
- control: this.control
465
- });
466
- }
467
- get state() {
468
- return this.sandbox.state;
469
- }
470
- inspector(options) {
471
- return this.sandbox.inspector(options);
472
- }
473
- async waitUntilReady(options) {
474
- assertOptionsObject(options);
475
- assertPositiveSafeInteger2(options.timeoutMs, "timeoutMs");
476
- this.assertRunning();
477
- const timeout = AbortSignal.timeout(options.timeoutMs);
478
- const abortSignal = options.abortSignal === void 0 ? timeout : AbortSignal.any([options.abortSignal, timeout]);
479
- try {
480
- await Promise.all([
481
- this.sandbox.runtime.waitForPort({
482
- containerPort: cdpPort,
483
- timeoutMs: options.timeoutMs,
484
- abortSignal
485
- }),
486
- this.sandbox.runtime.waitForPort({
487
- containerPort: noVncPort,
488
- timeoutMs: options.timeoutMs,
489
- abortSignal
490
- })
491
- ]);
492
- await Promise.all([
493
- assertHttpReady(`${endpointFor(this.sandbox, cdpPort)}/json/version`, abortSignal),
494
- assertHttpReady(`${endpointFor(this.sandbox, noVncPort)}/vnc.html`, abortSignal)
495
- ]);
2032
+ await assertBrowserImage(sandbox, bounded.signal);
2033
+ await startBrowserServices(sandbox, bounded.signal);
2034
+ bounded.throwIfAborted();
2035
+ return new DockerBrowserHandle(sandbox);
496
2036
  } catch (error) {
497
- if (options.abortSignal?.aborted) options.abortSignal.throwIfAborted();
498
- throw new BrowserError("Browser did not become ready before the timeout.", "not_ready", {
499
- cause: error
500
- });
501
- }
502
- }
503
- async connect(options = {}) {
504
- assertOptionsObject(options);
505
- this.assertRunning();
506
- try {
507
- let connectOptions = {
508
- endpointUrl: endpointFor(this.sandbox, cdpPort),
509
- control: this.control
510
- };
511
- if (options.abortSignal !== void 0) {
512
- connectOptions = { ...connectOptions, abortSignal: options.abortSignal };
2037
+ let cleanupError;
2038
+ try {
2039
+ await sandbox?.stop();
2040
+ } catch (caught) {
2041
+ cleanupError = caught;
513
2042
  }
514
- const connection = await connectPlaywrightBrowser(connectOptions);
515
- this.connections.add(connection);
516
- return connection;
517
- } catch (error) {
518
- if (options.abortSignal?.aborted) options.abortSignal.throwIfAborted();
519
- throw new BrowserError("Unable to connect to Chromium over CDP.", "not_ready", {
520
- cause: error
521
- });
522
- }
523
- }
524
- async stop(options = {}) {
525
- assertOptionsObject(options);
526
- await this.disconnectAll();
527
- await this.sandbox.stop(options);
528
- }
529
- async destroy() {
530
- this.control.destroy();
531
- await this.disconnectAll();
532
- await this.sandbox.destroy();
533
- }
534
- async [Symbol.asyncDispose]() {
535
- await this.destroy();
536
- }
537
- assertRunning() {
538
- if (this.state !== "running") {
539
- throw new BrowserError(`Browser is not running: ${this.state}`, "invalid_state");
540
- }
541
- }
542
- async disconnectAll() {
543
- const connections = [...this.connections];
544
- this.connections.clear();
545
- const results = await Promise.allSettled(
546
- connections.map((connection) => connection.disconnect())
547
- );
548
- const failures = results.filter((result) => result.status === "rejected").map((result) => result.reason);
549
- if (failures.length > 0) {
550
- throw new AggregateError(failures, "Unable to disconnect browser automation clients.");
2043
+ throw bounded.normalize(
2044
+ combineCleanupError2(error, cleanupError, "Resumed browser cleanup also failed."),
2045
+ "Unable to resume browser services.",
2046
+ "startup_failed"
2047
+ );
2048
+ } finally {
2049
+ bounded.dispose();
551
2050
  }
552
2051
  }
553
2052
  };
554
- async function configureBrowser(sandbox, options) {
555
- let execOptions = {
2053
+ async function configureBrowser(sandbox, options, abortSignal) {
2054
+ const result = await sandbox.runtime.exec({
556
2055
  command: "/usr/local/bin/anvia-browser-configure",
557
2056
  input: JSON.stringify({
558
2057
  password: options.desktop.password,
559
2058
  width: options.desktop.viewport.width,
560
2059
  height: options.desktop.viewport.height
561
- })
562
- };
563
- if (options.abortSignal !== void 0) {
564
- execOptions = { ...execOptions, abortSignal: options.abortSignal };
565
- }
566
- const result = await sandbox.runtime.exec(execOptions);
2060
+ }),
2061
+ abortSignal
2062
+ });
567
2063
  if (result.status !== "exited" || result.exitCode !== 0) {
568
2064
  throw new Error("Browser image rejected its runtime configuration.");
569
2065
  }
570
2066
  }
571
2067
  async function assertBrowserImage(sandbox, abortSignal) {
572
- let execOptions = {
573
- command: "/usr/local/bin/anvia-browser-version"
574
- };
575
- if (abortSignal !== void 0) execOptions = { ...execOptions, abortSignal };
576
- const result = await sandbox.runtime.exec(execOptions);
2068
+ const result = await sandbox.runtime.exec({
2069
+ command: "/usr/local/bin/anvia-browser-version",
2070
+ abortSignal
2071
+ });
577
2072
  if (result.status !== "exited" || result.exitCode !== 0 || new TextDecoder("utf-8", { fatal: true }).decode(result.stdout).trim() !== browserSchema) {
578
2073
  throw new Error("Sandbox does not contain a compatible Anvia browser image.");
579
2074
  }
580
2075
  }
581
2076
  async function startBrowserServices(sandbox, abortSignal) {
582
- let startOptions = {
583
- command: "/usr/local/bin/anvia-browser-start"
584
- };
585
- if (abortSignal !== void 0) startOptions = { ...startOptions, abortSignal };
586
- await sandbox.runtime.startProcess(startOptions);
587
- }
588
- async function assertHttpReady(url, abortSignal) {
589
- while (true) {
590
- abortSignal.throwIfAborted();
591
- try {
592
- const response = await fetch(url, { signal: abortSignal, redirect: "error" });
593
- if (response.ok) {
594
- await response.body?.cancel();
595
- return;
596
- }
597
- await response.body?.cancel();
598
- } catch (error) {
599
- if (abortSignal.aborted) throw abortSignal.reason ?? error;
600
- }
601
- await waitForRetry(abortSignal);
602
- }
603
- }
604
- async function waitForRetry(abortSignal) {
605
- await new Promise((resolve, reject) => {
606
- const timeout = setTimeout(finish, 50);
607
- const abort = () => finish(abortSignal.reason ?? new DOMException("Aborted", "AbortError"));
608
- abortSignal.addEventListener("abort", abort, { once: true });
609
- function finish(error) {
610
- clearTimeout(timeout);
611
- abortSignal.removeEventListener("abort", abort);
612
- if (error === void 0) resolve();
613
- else reject(error);
614
- }
2077
+ await sandbox.runtime.startProcess({
2078
+ command: "/usr/local/bin/anvia-browser-start",
2079
+ abortSignal
615
2080
  });
616
2081
  }
617
- function endpointFor(sandbox, containerPort) {
618
- const published = publishedPort(sandbox, containerPort);
619
- return `http://${hostForUrl(published.host)}:${published.hostPort}`;
620
- }
621
- function publishedPort(sandbox, containerPort) {
622
- const port = sandbox.runtime.publishedPorts.find(
623
- (candidate) => candidate.containerPort === containerPort && candidate.protocol === "tcp"
624
- );
625
- if (port === void 0) {
626
- throw new BrowserError(`Browser port is not published: ${containerPort}`, "invalid_state");
627
- }
628
- return port;
629
- }
630
- function hostForUrl(host) {
631
- return host.includes(":") ? `[${host}]` : host;
632
- }
633
2082
  function validateCreateOptions(options) {
634
- assertOptionsObject(options);
2083
+ assertOptionsObject2(options);
635
2084
  if (options.id !== void 0) assertNonEmptyString(options.id, "id");
636
- if (!isRecord2(options.workspace)) throw new TypeError("workspace must be an object.");
637
- if (!isRecord2(options.network) || options.network.mode !== "bridge") {
2085
+ if (!isRecord3(options.workspace)) throw new TypeError("workspace must be an object.");
2086
+ if (!isRecord3(options.network) || options.network.mode !== "bridge") {
638
2087
  throw new TypeError('network must be { mode: "bridge" }.');
639
2088
  }
640
- if (!isRecord2(options.desktop) || options.desktop.protocol !== "novnc") {
2089
+ if (!isRecord3(options.desktop) || options.desktop.protocol !== "novnc") {
641
2090
  throw new TypeError('desktop must use protocol: "novnc".');
642
2091
  }
643
2092
  if (!/^[\x20-\x7e]{8}$/.test(options.desktop.password)) {
644
2093
  throw new TypeError("desktop.password must contain exactly 8 printable ASCII characters.");
645
2094
  }
646
- if (!isRecord2(options.desktop.viewport)) {
2095
+ if (!isRecord3(options.desktop.viewport)) {
647
2096
  throw new TypeError("desktop.viewport must be an object.");
648
2097
  }
649
2098
  assertIntegerInRange(options.desktop.viewport.width, "desktop.viewport.width", 640, 3840);
650
2099
  assertIntegerInRange(options.desktop.viewport.height, "desktop.viewport.height", 480, 2160);
2100
+ if (options.timeoutMs !== void 0) assertPositiveSafeInteger4(options.timeoutMs, "timeoutMs");
651
2101
  if (options.runtime?.maxProcesses !== void 0 && options.runtime.maxProcesses < 1) {
652
2102
  throw new RangeError("runtime.maxProcesses must allow the browser service process.");
653
2103
  }
@@ -657,24 +2107,16 @@ function assertIntegerInRange(value, name, min, max) {
657
2107
  throw new RangeError(`${name} must be a safe integer between ${min} and ${max}.`);
658
2108
  }
659
2109
  }
660
- function assertPositiveSafeInteger2(value, name) {
661
- if (!Number.isSafeInteger(value) || value <= 0) {
662
- throw new RangeError(`${name} must be a positive safe integer.`);
663
- }
664
- }
665
2110
  function assertNonEmptyString(value, name) {
666
2111
  if (typeof value !== "string" || value.length === 0) {
667
2112
  throw new TypeError(`${name} must be a non-empty string.`);
668
2113
  }
669
2114
  }
670
- function assertOptionsObject(value) {
671
- if (!isRecord2(value)) throw new TypeError("options must be an object.");
672
- }
673
2115
  function isSandboxClient(value) {
674
- return isRecord2(value) && typeof value.pullImage === "function" && typeof value.createSandbox === "function" && typeof value.resumeSandbox === "function";
2116
+ return isRecord3(value) && typeof value.pullImage === "function" && typeof value.createSandbox === "function" && typeof value.resumeSandbox === "function";
675
2117
  }
676
- function isRecord2(value) {
677
- return typeof value === "object" && value !== null && !Array.isArray(value);
2118
+ function combineCleanupError2(error, cleanupError, message) {
2119
+ return cleanupError === void 0 ? error : new AggregateError([error, cleanupError], message);
678
2120
  }
679
2121
 
680
2122
  // src/tools.ts
@@ -699,15 +2141,18 @@ var targetSchema = z.discriminatedUnion("by", [
699
2141
  ]);
700
2142
  var noInput = z.object({});
701
2143
  var tabInput = z.object({ tabId: z.string().uuid() });
2144
+ var explicitTab = { tabId: z.string().uuid().optional() };
702
2145
  var navigateInput = z.object({
2146
+ ...explicitTab,
703
2147
  url: z.url(),
704
2148
  waitUntil: z.enum(["commit", "domcontentloaded", "load", "networkidle"]).optional()
705
2149
  });
706
- var clickInput = z.object({ target: targetSchema });
707
- var typeInput = z.object({ target: targetSchema, text: z.string() });
708
- var pressKeyInput = z.object({ key: z.string().min(1).max(100) });
709
- var screenshotInput = z.object({});
710
- var defaultActionTimeoutMs = 1e4;
2150
+ var clickInput = z.object({ ...explicitTab, target: targetSchema });
2151
+ var typeInput = z.object({ ...explicitTab, target: targetSchema, text: z.string() });
2152
+ var pressKeyInput = z.object({ ...explicitTab, key: z.string().min(1).max(100) });
2153
+ var screenshotInput = z.object(explicitTab);
2154
+ var snapshotInput = z.object(explicitTab);
2155
+ var defaultActionTimeoutMs2 = 1e4;
711
2156
  var defaultNavigationTimeoutMs = 3e4;
712
2157
  var defaultSnapshotMaxChars = 5e4;
713
2158
  var maxTimeoutMs = 3e5;
@@ -718,20 +2163,20 @@ function createBrowserTools(options) {
718
2163
  const policy = snapshotNavigationPolicy(options.navigation);
719
2164
  connection.setNavigationPolicy(policy);
720
2165
  const limits = {
721
- actionTimeoutMs: options.limits?.actionTimeoutMs ?? defaultActionTimeoutMs,
2166
+ actionTimeoutMs: options.limits?.actionTimeoutMs ?? defaultActionTimeoutMs2,
722
2167
  navigationTimeoutMs: options.limits?.navigationTimeoutMs ?? defaultNavigationTimeoutMs,
723
2168
  snapshotMaxChars: options.limits?.snapshotMaxChars ?? defaultSnapshotMaxChars
724
2169
  };
725
2170
  const tools = options.tools.map((name) => {
726
2171
  switch (name) {
727
2172
  case "browser_list_tabs":
728
- return createListTabsTool(connection);
2173
+ return createListTabsTool(connection, limits.actionTimeoutMs);
729
2174
  case "browser_open_tab":
730
- return createOpenTabTool(connection);
2175
+ return createOpenTabTool(connection, limits.actionTimeoutMs);
731
2176
  case "browser_select_tab":
732
- return createSelectTabTool(connection);
2177
+ return createSelectTabTool(connection, limits.actionTimeoutMs);
733
2178
  case "browser_close_tab":
734
- return createCloseTabTool(connection);
2179
+ return createCloseTabTool(connection, limits.actionTimeoutMs);
735
2180
  case "browser_navigate":
736
2181
  return createNavigateTool(connection, policy, limits.navigationTimeoutMs);
737
2182
  case "browser_snapshot":
@@ -741,185 +2186,174 @@ function createBrowserTools(options) {
741
2186
  case "browser_type":
742
2187
  return createTypeTool(connection, limits.actionTimeoutMs);
743
2188
  case "browser_press_key":
744
- return createPressKeyTool(connection);
2189
+ return createPressKeyTool(connection, limits.actionTimeoutMs);
745
2190
  case "browser_screenshot":
746
- return createScreenshotTool(connection);
2191
+ return createScreenshotTool(connection, limits.actionTimeoutMs);
747
2192
  default:
748
2193
  return assertNever(name);
749
2194
  }
750
2195
  });
751
2196
  return Object.freeze(tools);
752
2197
  }
753
- function createListTabsTool(connection) {
2198
+ function createListTabsTool(connection, timeoutMs) {
754
2199
  return createTool({
755
2200
  name: "browser_list_tabs",
756
2201
  description: "List Chromium tabs and identify the tab selected for subsequent browser tools.",
757
2202
  inputSchema: noInput,
758
- execute: async () => ({ tabs: await connection.listTabs() })
2203
+ execute: async (_args, context) => ({
2204
+ tabs: await connection.listTabs({ abortSignal: context.abortSignal, timeoutMs })
2205
+ })
759
2206
  });
760
2207
  }
761
- function createOpenTabTool(connection) {
2208
+ function createOpenTabTool(connection, timeoutMs) {
762
2209
  return createTool({
763
2210
  name: "browser_open_tab",
764
2211
  description: "Open and select a new blank Chromium tab.",
765
2212
  inputSchema: noInput,
766
- execute: async (_args, context) => connection.runAction(context.abortSignal, async () => {
767
- const page = await connection.openTab();
768
- return tabResult(connection, page);
769
- })
2213
+ execute: async (_args, context) => connection.openTab(context.abortSignal, timeoutMs)
770
2214
  });
771
2215
  }
772
- function createSelectTabTool(connection) {
2216
+ function createSelectTabTool(connection, timeoutMs) {
773
2217
  return createTool({
774
2218
  name: "browser_select_tab",
775
2219
  description: "Select an existing Chromium tab by its browser tab ID.",
776
2220
  inputSchema: tabInput,
777
- execute: async ({ tabId }, context) => connection.runAction(
778
- context.abortSignal,
779
- async () => tabResult(connection, connection.selectTab(tabId))
780
- )
2221
+ execute: async ({ tabId }, context) => connection.selectTab(tabId, context.abortSignal, timeoutMs)
781
2222
  });
782
2223
  }
783
- function createCloseTabTool(connection) {
2224
+ function createCloseTabTool(connection, timeoutMs) {
784
2225
  return createTool({
785
2226
  name: "browser_close_tab",
786
2227
  description: "Close an existing Chromium tab by its browser tab ID.",
787
2228
  inputSchema: tabInput,
788
- execute: async ({ tabId }, context) => connection.runAction(context.abortSignal, async () => {
789
- await connection.closeTab(tabId);
790
- return { closedTabId: tabId, tabs: await connection.tabSummaries() };
791
- })
2229
+ execute: async ({ tabId }, context) => connection.closeTab(tabId, context.abortSignal, timeoutMs)
792
2230
  });
793
2231
  }
794
2232
  function createNavigateTool(connection, policy, timeoutMs) {
795
2233
  return createTool({
796
2234
  name: "browser_navigate",
797
- description: "Navigate the selected Chromium tab to an allowed HTTP or HTTPS URL.",
2235
+ description: "Navigate an explicit tab, or the selected tab in serial compatibility mode, to an allowed HTTP or HTTPS URL.",
798
2236
  inputSchema: navigateInput,
799
- execute: async ({ url, waitUntil }, context) => connection.runAction(context.abortSignal, async () => {
2237
+ execute: async ({ tabId, url, waitUntil }, context) => {
800
2238
  assertNavigationAllowed(url, policy);
801
- const page = connection.selectedPage();
802
- await page.goto(url, {
803
- timeout: timeoutMs,
804
- waitUntil: waitUntil ?? "load"
2239
+ const result = await connection.runTabCommand({
2240
+ tabId,
2241
+ abortSignal: context.abortSignal,
2242
+ timeoutMs,
2243
+ phase: "navigate",
2244
+ command: (targetTabId) => ({
2245
+ method: "navigate",
2246
+ params: {
2247
+ tabId: targetTabId,
2248
+ url,
2249
+ waitUntil: waitUntil ?? "load",
2250
+ timeoutMs
2251
+ }
2252
+ })
805
2253
  });
806
- assertNavigationAllowed(page.url(), policy);
807
- return tabResult(connection, page);
808
- })
2254
+ assertNavigationAllowed(result.url, policy);
2255
+ return result;
2256
+ }
809
2257
  });
810
2258
  }
811
2259
  function createSnapshotTool(connection, timeoutMs, maxChars) {
812
2260
  return createTool({
813
2261
  name: "browser_snapshot",
814
- description: "Inspect the selected tab using a bounded ARIA accessibility snapshot.",
815
- inputSchema: noInput,
816
- execute: async (_args, context) => connection.runAction(context.abortSignal, async () => {
817
- const page = connection.selectedPage();
818
- const snapshot = await page.locator("body").ariaSnapshot({ timeout: timeoutMs });
819
- const truncated = snapshot.length > maxChars;
820
- return {
821
- ...await tabResult(connection, page),
822
- snapshot: truncated ? snapshot.slice(0, maxChars) : snapshot,
823
- truncated
824
- };
2262
+ description: "Inspect an explicit tab, or the selected tab in serial compatibility mode, using a bounded ARIA accessibility snapshot.",
2263
+ inputSchema: snapshotInput,
2264
+ execute: async ({ tabId }, context) => connection.runTabCommand({
2265
+ tabId,
2266
+ abortSignal: context.abortSignal,
2267
+ timeoutMs,
2268
+ phase: "snapshot",
2269
+ command: (targetTabId) => ({
2270
+ method: "snapshot",
2271
+ params: { tabId: targetTabId, timeoutMs, maxChars }
2272
+ })
825
2273
  })
826
2274
  });
827
2275
  }
828
2276
  function createClickTool(connection, timeoutMs) {
829
2277
  return createTool({
830
2278
  name: "browser_click",
831
- description: "Click one strictly matched element in the selected tab.",
2279
+ description: "Click one strictly matched element in an explicit tab or the selected tab.",
832
2280
  inputSchema: clickInput,
833
- execute: async ({ target }, context) => connection.runAction(context.abortSignal, async () => {
834
- const page = connection.selectedPage();
835
- await locatorFor(page, target).click({ timeout: timeoutMs });
836
- return tabResult(connection, page);
2281
+ execute: async ({ tabId, target }, context) => connection.runTabCommand({
2282
+ tabId,
2283
+ abortSignal: context.abortSignal,
2284
+ timeoutMs,
2285
+ phase: "click",
2286
+ command: (targetTabId) => ({
2287
+ method: "click",
2288
+ params: { tabId: targetTabId, target, timeoutMs }
2289
+ })
837
2290
  })
838
2291
  });
839
2292
  }
840
2293
  function createTypeTool(connection, timeoutMs) {
841
2294
  return createTool({
842
2295
  name: "browser_type",
843
- description: "Replace the value of one strictly matched editable element.",
2296
+ description: "Replace the value of one strictly matched editable element in an explicit or selected tab.",
844
2297
  inputSchema: typeInput,
845
- execute: async ({ target, text }, context) => connection.runAction(context.abortSignal, async () => {
846
- const page = connection.selectedPage();
847
- await locatorFor(page, target).fill(text, { timeout: timeoutMs });
848
- return tabResult(connection, page);
2298
+ execute: async ({ tabId, target, text }, context) => connection.runTabCommand({
2299
+ tabId,
2300
+ abortSignal: context.abortSignal,
2301
+ timeoutMs,
2302
+ phase: "type",
2303
+ command: (targetTabId) => ({
2304
+ method: "type",
2305
+ params: { tabId: targetTabId, target, text, timeoutMs }
2306
+ })
849
2307
  })
850
2308
  });
851
2309
  }
852
- function createPressKeyTool(connection) {
2310
+ function createPressKeyTool(connection, timeoutMs) {
853
2311
  return createTool({
854
2312
  name: "browser_press_key",
855
- description: "Press an explicit keyboard key or key combination in the selected tab.",
2313
+ description: "Press an explicit keyboard key or key combination in an explicit or selected tab.",
856
2314
  inputSchema: pressKeyInput,
857
- execute: async ({ key }, context) => connection.runAction(context.abortSignal, async () => {
858
- const page = connection.selectedPage();
859
- await page.keyboard.press(key);
860
- return tabResult(connection, page);
2315
+ execute: async ({ tabId, key }, context) => connection.runTabCommand({
2316
+ tabId,
2317
+ abortSignal: context.abortSignal,
2318
+ timeoutMs,
2319
+ phase: "press-key",
2320
+ command: (targetTabId) => ({
2321
+ method: "pressKey",
2322
+ params: { tabId: targetTabId, key }
2323
+ })
861
2324
  })
862
2325
  });
863
2326
  }
864
- function createScreenshotTool(connection) {
2327
+ function createScreenshotTool(connection, timeoutMs) {
865
2328
  return createTool({
866
2329
  name: "browser_screenshot",
867
- description: "Capture the visible viewport of the selected Chromium tab as PNG.",
2330
+ description: "Capture the visible viewport of an explicit or selected Chromium tab as PNG.",
868
2331
  inputSchema: screenshotInput,
869
- execute: async (_args, context) => connection.runAction(context.abortSignal, async () => {
870
- const page = connection.selectedPage();
871
- const png = await page.screenshot({ type: "png", fullPage: false });
872
- const metadata = await tabResult(connection, page);
2332
+ execute: async ({ tabId }, context) => {
2333
+ const result = await connection.runTabCommand({
2334
+ tabId,
2335
+ abortSignal: context.abortSignal,
2336
+ timeoutMs,
2337
+ phase: "screenshot",
2338
+ command: (targetTabId) => ({
2339
+ method: "screenshot",
2340
+ params: { tabId: targetTabId, timeoutMs }
2341
+ })
2342
+ });
873
2343
  return ToolOutput.content([
874
- { type: "text", text: JSON.stringify(metadata) },
2344
+ { type: "text", text: JSON.stringify(result.metadata) },
875
2345
  {
876
2346
  type: "file",
877
- data: { type: "data", data: png.toString("base64") },
2347
+ data: { type: "data", data: result.pngBase64 },
878
2348
  mediaType: "image/png",
879
2349
  filename: "browser-screenshot.png"
880
2350
  }
881
2351
  ]);
882
- })
883
- });
884
- }
885
- async function tabResult(connection, page) {
886
- return {
887
- tabId: connection.idFor(page),
888
- title: await page.title(),
889
- url: page.url()
890
- };
891
- }
892
- function locatorFor(page, target) {
893
- switch (target.by) {
894
- case "role": {
895
- const options = {};
896
- if (target.name !== void 0) options.name = target.name;
897
- if (target.exact !== void 0) options.exact = target.exact;
898
- return page.getByRole(target.role, options);
899
- }
900
- case "text": {
901
- const options = {};
902
- if (target.exact !== void 0) options.exact = target.exact;
903
- return page.getByText(target.text, options);
904
- }
905
- case "label": {
906
- const options = {};
907
- if (target.exact !== void 0) options.exact = target.exact;
908
- return page.getByLabel(target.label, options);
909
- }
910
- case "placeholder": {
911
- const options = {};
912
- if (target.exact !== void 0) options.exact = target.exact;
913
- return page.getByPlaceholder(target.placeholder, options);
914
2352
  }
915
- case "test-id":
916
- return page.getByTestId(target.testId);
917
- case "css":
918
- return page.locator(target.selector);
919
- }
2353
+ });
920
2354
  }
921
2355
  function snapshotNavigationPolicy(policy) {
922
- if (!isRecord3(policy)) throw new TypeError("navigation must be an object.");
2356
+ if (!isRecord5(policy)) throw new TypeError("navigation must be an object.");
923
2357
  if (policy.mode === "allow-all-http") return Object.freeze({ mode: "allow-all-http" });
924
2358
  if (policy.mode !== "origins" || !Array.isArray(policy.origins) || policy.origins.length === 0) {
925
2359
  throw new TypeError(
@@ -941,11 +2375,11 @@ function normalizeOrigin(value) {
941
2375
  return url.origin;
942
2376
  }
943
2377
  function assertNavigationAllowed(value, policy) {
944
- if (!isNavigationAllowed2(value, policy)) {
2378
+ if (!isNavigationAllowed(value, policy)) {
945
2379
  throw new BrowserError(`Browser navigation is blocked: ${value}`, "navigation_blocked");
946
2380
  }
947
2381
  }
948
- function isNavigationAllowed2(value, policy) {
2382
+ function isNavigationAllowed(value, policy) {
949
2383
  let url;
950
2384
  try {
951
2385
  url = parseHttpUrl(value);
@@ -965,7 +2399,7 @@ function parseHttpUrl(value) {
965
2399
  return url;
966
2400
  }
967
2401
  function validateFactoryOptions(options) {
968
- if (!isRecord3(options)) throw new TypeError("options must be an object.");
2402
+ if (!isRecord5(options)) throw new TypeError("options must be an object.");
969
2403
  if (!Array.isArray(options.tools) || options.tools.length === 0) {
970
2404
  throw new TypeError("tools must be a non-empty array.");
971
2405
  }
@@ -976,7 +2410,7 @@ function validateFactoryOptions(options) {
976
2410
  seen.add(name);
977
2411
  }
978
2412
  snapshotNavigationPolicy(options.navigation);
979
- if (options.limits !== void 0 && !isRecord3(options.limits)) {
2413
+ if (options.limits !== void 0 && !isRecord5(options.limits)) {
980
2414
  throw new TypeError("limits must be an object.");
981
2415
  }
982
2416
  assertOptionalBoundedInteger(options.limits?.actionTimeoutMs, "actionTimeoutMs", 1, maxTimeoutMs);
@@ -1012,7 +2446,7 @@ function isToolName(value) {
1012
2446
  "browser_screenshot"
1013
2447
  ].includes(value);
1014
2448
  }
1015
- function isRecord3(value) {
2449
+ function isRecord5(value) {
1016
2450
  return typeof value === "object" && value !== null && !Array.isArray(value);
1017
2451
  }
1018
2452
  function assertNever(value) {