@anvia/browser 1.0.8 → 1.0.11

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