@orkestrel/browser 0.0.2 → 0.0.5

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.
@@ -2,11 +2,14 @@ import { BROWSER_DEFAULT_TIMEOUT_MS, BROWSER_WAIT_POLL_INTERVAL_MS, BrowserConte
2
2
  import { existsSync, globSync } from "node:fs";
3
3
  import { dirname, posix, win32 } from "node:path";
4
4
  import { spawn, spawnSync } from "node:child_process";
5
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
5
6
  import { isRecord, isString } from "@orkestrel/contract";
6
7
  import { randomBytes } from "node:crypto";
7
8
  import { request } from "node:http";
9
+ import { request as request$1 } from "node:https";
8
10
  import { Emitter } from "@orkestrel/emitter";
9
- import { WEBSOCKET_READY_OPEN, WEBSOCKET_VERSION, computeWebSocketAccept, createNodeWebSocket } from "@orkestrel/websocket";
11
+ import { WEBSOCKET_READY_CLOSED, WEBSOCKET_READY_OPEN, WEBSOCKET_VERSION, computeWebSocketAccept, createNodeWebSocket } from "@orkestrel/websocket";
12
+ import { addAbortListener, once } from "node:events";
10
13
  import { mkdir, writeFile } from "node:fs/promises";
11
14
  //#region src/server/constants.ts
12
15
  /** Default CDP port probed for an existing browser and used for launches. */
@@ -19,13 +22,6 @@ var BROWSER_CDP_PROTOCOL = "http";
19
22
  var BROWSER_CDP_VERSION_PATH = "/json/version";
20
23
  /** Path appended to the CDP host to list open targets (pages, workers, etc). */
21
24
  var BROWSER_CDP_LIST_PATH = "/json/list";
22
- /** Sentinel result returned by discovery when no browser is reachable. */
23
- var BROWSER_NOT_FOUND_RESULT = Object.freeze({
24
- found: false,
25
- endpoint: void 0,
26
- browser: void 0,
27
- connection: void 0
28
- });
29
25
  /** Flags always passed to a launched browser process, alongside the caller's own. */
30
26
  var BROWSER_LAUNCH_ARGS = Object.freeze(["--no-first-run", "--no-default-browser-check"]);
31
27
  /** Flag enabling headless mode on a launched browser process. */
@@ -36,6 +32,10 @@ var BROWSER_KILL_GRACE_MS = 3e3;
36
32
  var BROWSER_PORT_PROBE_TIMEOUT_MS = 200;
37
33
  /** Brief defer applied once when a transport loss is observed on an owned process, giving a near-simultaneous process-exit event (which libuv may reap slightly later than the socket close) first say over the diagnosis. */
38
34
  var BROWSER_TRANSPORT_LOSS_DEFER_MS = 50;
35
+ /** Machine-readable error-context cause for an owned browser process exiting. */
36
+ var BROWSER_PROCESS_EXIT_CAUSE = "process-exit";
37
+ /** Machine-readable error-context cause for a CDP transport disconnecting while its browser remains alive. */
38
+ var BROWSER_TRANSPORT_LOSS_CAUSE = "transport-loss";
39
39
  /** Environment variables checked (in order) for an explicit browser executable path override. */
40
40
  var BROWSER_ENV_PATH_KEYS = Object.freeze(["PLAYWRIGHT_EXECUTABLE_PATH", "CHROME_PATH"]);
41
41
  /** Well-known Chrome/Chromium/Edge executable paths with no platform-specific root, keyed by `process.platform`. */
@@ -259,8 +259,8 @@ function normalizeExecutablePath(path, platform) {
259
259
  }
260
260
  /** Classify a `/json/version` `Browser` string into a {@link BrowserEngine} (`Edg/` → edge, `Chrome/` → chrome, else chromium). */
261
261
  function browserToEngine(browser) {
262
- if (browser.includes("Edg/")) return "edge";
263
- if (browser.includes("Chrome/")) return "chrome";
262
+ if (browser?.includes("Edg/") === true) return "edge";
263
+ if (browser?.includes("Chrome/") === true) return "chrome";
264
264
  return "chromium";
265
265
  }
266
266
  /** Check the env-override keys (`PLAYWRIGHT_EXECUTABLE_PATH`, `CHROME_PATH`) in order for an existing file. */
@@ -346,7 +346,7 @@ function findAllInStore(base, platform) {
346
346
  const glob = BROWSER_STORE_GLOBS[platform];
347
347
  if (glob === void 0) return found;
348
348
  const matches = globSync(`${base.replaceAll("\\", "/")}/${glob}`).filter((match) => existsSync(match));
349
- matches.sort((a, b) => a < b ? 1 : a > b ? -1 : 0);
349
+ matches.sort((a, b) => b.localeCompare(a, void 0, { numeric: true }));
350
350
  found.push(...matches);
351
351
  return found;
352
352
  }
@@ -378,23 +378,24 @@ function launchBrowserProcess(executable, port, headless, profile, extra) {
378
378
  *
379
379
  * @throws When the endpoint does not become ready before the timeout
380
380
  */
381
- async function waitForCdpReady(port, timeout, host = BROWSER_DEFAULT_HOST) {
381
+ async function waitForCDPReady(port, timeout, host = BROWSER_DEFAULT_HOST, signal) {
382
382
  const url = `${BROWSER_CDP_PROTOCOL}://${host}:${port}${BROWSER_CDP_VERSION_PATH}`;
383
383
  const deadline = Date.now() + timeout;
384
384
  while (Date.now() < deadline) {
385
+ signal?.throwIfAborted();
385
386
  const remaining = Math.max(0, deadline - Date.now());
386
- const controller = new AbortController();
387
- const timer = setTimeout(() => controller.abort(), remaining);
387
+ const requestSignal = signal === void 0 ? AbortSignal.timeout(remaining) : AbortSignal.any([signal, AbortSignal.timeout(remaining)]);
388
388
  try {
389
- const response = await fetch(url, { signal: controller.signal });
389
+ const response = await fetch(url, { signal: requestSignal });
390
390
  if (response.ok) {
391
391
  const info = await response.json();
392
392
  if (isRecord(info) && isString(info["webSocketDebuggerUrl"])) return info["webSocketDebuggerUrl"];
393
393
  }
394
- } catch {} finally {
395
- clearTimeout(timer);
394
+ } catch (error) {
395
+ if (signal?.aborted === true) throw error;
396
396
  }
397
- await new Promise((resolve) => setTimeout(resolve, BROWSER_WAIT_POLL_INTERVAL_MS));
397
+ const delay = Math.min(BROWSER_WAIT_POLL_INTERVAL_MS, Math.max(0, deadline - Date.now()));
398
+ if (delay > 0) await setTimeout$1(delay, void 0, { signal });
398
399
  }
399
400
  throw new Error(`CDP endpoint on port ${port} did not become ready within ${timeout}ms`);
400
401
  }
@@ -406,7 +407,7 @@ async function waitForCdpReady(port, timeout, host = BROWSER_DEFAULT_HOST) {
406
407
  * @param host - Host the browser exposes its CDP endpoint on (default `127.0.0.1`)
407
408
  * @returns Normalized CDP targets
408
409
  */
409
- async function fetchCdpTargets(port, timeout, host = BROWSER_DEFAULT_HOST) {
410
+ async function fetchCDPTargets(port, timeout, host = BROWSER_DEFAULT_HOST) {
410
411
  const url = `${BROWSER_CDP_PROTOCOL}://${host}:${port}${BROWSER_CDP_LIST_PATH}`;
411
412
  const controller = new AbortController();
412
413
  const timer = setTimeout(() => controller.abort(), timeout);
@@ -418,12 +419,12 @@ async function fetchCdpTargets(port, timeout, host = BROWSER_DEFAULT_HOST) {
418
419
  const targets = [];
419
420
  for (const entry of list) {
420
421
  if (!isRecord(entry)) continue;
421
- if (!isString(entry["id"]) || !isString(entry["type"])) continue;
422
+ if (!isString(entry["id"]) || !isString(entry["type"]) || !isString(entry["title"]) || !isString(entry["url"])) continue;
422
423
  targets.push({
423
424
  id: entry["id"],
424
425
  type: entry["type"],
425
- title: isString(entry["title"]) ? entry["title"] : "",
426
- url: isString(entry["url"]) ? entry["url"] : ""
426
+ title: entry["title"],
427
+ url: entry["url"]
427
428
  });
428
429
  }
429
430
  return targets;
@@ -436,33 +437,32 @@ async function fetchCdpTargets(port, timeout, host = BROWSER_DEFAULT_HOST) {
436
437
  //#endregion
437
438
  //#region src/server/transports/WebSocketCDPTransport.ts
438
439
  /**
439
- * `@orkestrel/websocket`-backed {@link CDPTransportInterface} — the dumb text
440
- * pipe a {@link import('@src/core').CDPClientInterface} sends and receives
441
- * JSON-RPC frames over.
440
+ * `@orkestrel/websocket`-backed raw CDP text transport.
442
441
  *
443
442
  * @remarks
444
- * `start()` hand-rolls the RFC 6455 client handshake: it opens a `node:http`
445
- * `GET` carrying `Connection: Upgrade` / `Upgrade: websocket` / a random
446
- * `Sec-WebSocket-Key` / `Sec-WebSocket-Version`, awaits the client `'upgrade'`
447
- * event, and validates `Sec-WebSocket-Accept === computeWebSocketAccept(key)`
448
- * — a mismatch (or a connection/request error, or a timeout) rejects `start()`
449
- * with a coded {@link BrowserConnectionError} and cleans up the socket. On
450
- * success it wraps the raw upgraded socket in `createNodeWebSocket({ socket,
451
- * head })` (CLIENT mode — no `key` — so outgoing frames are masked per RFC
452
- * 6455 §5.3) and bridges its `message`/`close`/`error` events onto this
453
- * transport's emitter. `start()` opens a fresh socket each call (safe to call
454
- * again after `close()` — a new request/socket/NodeWebSocket triple is
455
- * created every time, which is what `CDPClient.reconnect()` depends on). CDP
456
- * frames are text-only, so `message` always carries a `string`.
443
+ * Performs and validates the RFC 6455 HTTP upgrade, then delegates frame
444
+ * handling to `@orkestrel/websocket`. Concurrent and repeated `start()` /
445
+ * `close()` calls share their active transition, and a later `start()` opens a
446
+ * fresh socket after the prior one closes.
457
447
  */
458
448
  var WebSocketCDPTransport = class {
459
449
  #emitter;
460
450
  #url;
461
451
  #timeout;
462
452
  #socket;
463
- #connecting;
453
+ #starting;
454
+ #closing;
455
+ #request;
456
+ #resolve;
457
+ #reject;
458
+ #timer;
459
+ #closeResolve;
460
+ #onSocketClose = () => this.#closeResolve?.();
464
461
  constructor(options) {
465
- this.#emitter = new Emitter();
462
+ this.#emitter = new Emitter({
463
+ on: options.on,
464
+ error: options.error
465
+ });
466
466
  this.#url = options.url;
467
467
  this.#timeout = options.timeout ?? BROWSER_DEFAULT_TIMEOUT_MS;
468
468
  }
@@ -470,62 +470,22 @@ var WebSocketCDPTransport = class {
470
470
  return this.#emitter;
471
471
  }
472
472
  async start() {
473
- const url = new URL(this.#url);
474
- const key = randomBytes(16).toString("base64");
475
- const ws = await new Promise((resolve, reject) => {
476
- const settle = (fn) => {
477
- this.#connecting = void 0;
478
- fn();
479
- };
480
- const timer = setTimeout(() => {
481
- request$1.destroy();
482
- settle(() => reject(new BrowserConnectionError(`WebSocket CDP connection to ${this.#url} timed out after ${this.#timeout}ms`, {
483
- url: this.#url,
484
- timeout: this.#timeout
485
- })));
486
- }, this.#timeout);
487
- const request$1 = request({
488
- hostname: url.hostname,
489
- port: url.port.length > 0 ? Number(url.port) : 80,
490
- path: `${url.pathname}${url.search}`,
491
- headers: {
492
- Connection: "Upgrade",
493
- Upgrade: "websocket",
494
- "Sec-WebSocket-Key": key,
495
- "Sec-WebSocket-Version": WEBSOCKET_VERSION
496
- }
497
- });
498
- request$1.on("upgrade", (response, socket, head) => {
499
- clearTimeout(timer);
500
- const accept = response.headers["sec-websocket-accept"];
501
- if (!isString(accept) || accept !== computeWebSocketAccept(key)) {
502
- socket.destroy();
503
- settle(() => reject(new BrowserConnectionError(`WebSocket CDP connection to ${this.#url} failed: Sec-WebSocket-Accept mismatch`, { url: this.#url })));
504
- return;
505
- }
506
- settle(() => resolve(createNodeWebSocket({
507
- socket,
508
- head
509
- })));
510
- });
511
- request$1.on("response", (response) => {
512
- clearTimeout(timer);
513
- response.resume();
514
- settle(() => reject(new BrowserConnectionError(`WebSocket CDP connection to ${this.#url} failed: upgrade declined with status ${response.statusCode ?? 0}`, { url: this.#url })));
515
- });
516
- request$1.on("error", (error) => {
517
- clearTimeout(timer);
518
- settle(() => reject(new BrowserConnectionError(`WebSocket CDP connection to ${this.#url} failed: ${error.message}`, { url: this.#url })));
519
- });
520
- this.#connecting = {
521
- abort: () => request$1.destroy(),
522
- timer,
523
- reject: (error) => settle(() => reject(error))
524
- };
525
- request$1.end();
526
- });
527
- this.#socket = ws;
528
- this.#bind(ws);
473
+ if (this.#closing !== void 0) await this.#closing;
474
+ if (this.#socket?.readyState === WEBSOCKET_READY_OPEN) return;
475
+ const active = this.#starting;
476
+ if (active !== void 0) {
477
+ await active;
478
+ return;
479
+ }
480
+ this.#socket?.destroy();
481
+ this.#socket = void 0;
482
+ const attempt = this.#open();
483
+ this.#starting = attempt;
484
+ try {
485
+ await attempt;
486
+ } finally {
487
+ if (this.#starting === attempt) this.#starting = void 0;
488
+ }
529
489
  }
530
490
  async send(data) {
531
491
  const socket = this.#socket;
@@ -533,32 +493,139 @@ var WebSocketCDPTransport = class {
533
493
  socket.send(data);
534
494
  }
535
495
  async close() {
536
- const connecting = this.#connecting;
537
- if (connecting !== void 0) {
538
- clearTimeout(connecting.timer);
539
- this.#connecting = void 0;
540
- connecting.abort();
541
- connecting.reject(new BrowserConnectionError(`WebSocket CDP connection to ${this.#url} was closed before it finished connecting`, { url: this.#url }));
496
+ const active = this.#closing;
497
+ if (active !== void 0) {
498
+ await active;
542
499
  return;
543
500
  }
501
+ const attempt = this.#stop();
502
+ this.#closing = attempt;
503
+ try {
504
+ await attempt;
505
+ } finally {
506
+ if (this.#closing === attempt) this.#closing = void 0;
507
+ }
508
+ }
509
+ async #open() {
510
+ const url = new URL(this.#url);
511
+ if (url.protocol !== "ws:" && url.protocol !== "wss:") throw new BrowserConnectionError(`WebSocket CDP connection requires a ws: or wss: URL: ${this.#url}`, { url: this.#url });
512
+ const key = randomBytes(16).toString("base64");
513
+ const deferred = Promise.withResolvers();
514
+ const options = {
515
+ hostname: url.hostname,
516
+ port: url.port.length > 0 ? Number(url.port) : url.protocol === "wss:" ? 443 : 80,
517
+ path: `${url.pathname}${url.search}`,
518
+ headers: {
519
+ Connection: "Upgrade",
520
+ Upgrade: "websocket",
521
+ "Sec-WebSocket-Key": key,
522
+ "Sec-WebSocket-Version": WEBSOCKET_VERSION
523
+ }
524
+ };
525
+ const request$2 = url.protocol === "wss:" ? request$1(options) : request(options);
526
+ this.#request = request$2;
527
+ this.#resolve = deferred.resolve;
528
+ this.#reject = deferred.reject;
529
+ this.#timer = setTimeout(() => this.#expire(request$2), this.#timeout);
530
+ request$2.on("upgrade", (response, socket, head) => {
531
+ this.#upgrade(request$2, key, response, socket, head);
532
+ });
533
+ request$2.on("response", (response) => this.#decline(request$2, response));
534
+ request$2.on("error", (error) => this.#fail(request$2, error));
535
+ request$2.end();
536
+ const socket = await deferred.promise;
537
+ this.#socket = socket;
538
+ this.#bind(socket);
539
+ }
540
+ async #stop() {
541
+ const request = this.#request;
542
+ if (request !== void 0) {
543
+ this.#rejectRequest(request, new BrowserConnectionError(`WebSocket CDP connection to ${this.#url} was closed before it finished connecting`, { url: this.#url }));
544
+ request.destroy();
545
+ }
546
+ await this.#starting?.catch(() => void 0);
544
547
  const socket = this.#socket;
545
- if (socket === void 0) return;
546
548
  this.#socket = void 0;
547
- await new Promise((resolve) => {
548
- socket.emitter.on("close", () => resolve());
549
+ if (socket === void 0 || socket.readyState === WEBSOCKET_READY_CLOSED) return;
550
+ const closed = Promise.withResolvers();
551
+ this.#closeResolve = closed.resolve;
552
+ socket.emitter.on("close", this.#onSocketClose);
553
+ try {
549
554
  socket.close();
550
- });
555
+ if (socket.readyState !== WEBSOCKET_READY_CLOSED) await closed.promise;
556
+ } catch {
557
+ socket.destroy();
558
+ if (socket.readyState !== WEBSOCKET_READY_CLOSED) await closed.promise;
559
+ } finally {
560
+ socket.emitter.off("close", this.#onSocketClose);
561
+ this.#closeResolve = void 0;
562
+ }
551
563
  }
552
- #bind(ws) {
553
- ws.emitter.on("message", (data) => {
554
- this.#emitter.emit("message", data);
555
- });
556
- ws.emitter.on("close", () => {
564
+ #upgrade(request, key, response, socket, head) {
565
+ if (this.#request !== request) {
566
+ socket.destroy();
567
+ return;
568
+ }
569
+ const accept = response.headers["sec-websocket-accept"];
570
+ if (!isString(accept) || accept !== computeWebSocketAccept(key)) {
571
+ socket.destroy();
572
+ this.#rejectRequest(request, new BrowserConnectionError(`WebSocket CDP connection to ${this.#url} failed: Sec-WebSocket-Accept mismatch`, { url: this.#url }));
573
+ return;
574
+ }
575
+ try {
576
+ this.#resolveRequest(request, createNodeWebSocket({
577
+ socket,
578
+ head
579
+ }));
580
+ } catch (error) {
581
+ socket.destroy();
582
+ this.#rejectRequest(request, error);
583
+ }
584
+ }
585
+ #decline(request, response) {
586
+ response.resume();
587
+ const reason = response.statusCode === void 0 ? "upgrade was declined without a status code" : `upgrade declined with status ${response.statusCode}`;
588
+ this.#rejectRequest(request, new BrowserConnectionError(`WebSocket CDP connection to ${this.#url} failed: ${reason}`, { url: this.#url }));
589
+ }
590
+ #fail(request, error) {
591
+ this.#rejectRequest(request, new BrowserConnectionError(`WebSocket CDP connection to ${this.#url} failed: ${error.message}`, { url: this.#url }));
592
+ }
593
+ #expire(request) {
594
+ this.#rejectRequest(request, new BrowserConnectionError(`WebSocket CDP connection to ${this.#url} timed out after ${this.#timeout}ms`, {
595
+ url: this.#url,
596
+ timeout: this.#timeout
597
+ }));
598
+ request.destroy();
599
+ }
600
+ #resolveRequest(request, socket) {
601
+ if (this.#request !== request) {
602
+ socket.destroy();
603
+ return;
604
+ }
605
+ const resolve = this.#resolve;
606
+ this.#clearRequest();
607
+ resolve?.(socket);
608
+ }
609
+ #rejectRequest(request, error) {
610
+ if (this.#request !== request) return;
611
+ const reject = this.#reject;
612
+ this.#clearRequest();
613
+ reject?.(error);
614
+ }
615
+ #clearRequest() {
616
+ if (this.#timer !== void 0) clearTimeout(this.#timer);
617
+ this.#timer = void 0;
618
+ this.#request = void 0;
619
+ this.#resolve = void 0;
620
+ this.#reject = void 0;
621
+ }
622
+ #bind(socket) {
623
+ socket.emitter.on("message", (data) => this.#emitter.emit("message", data));
624
+ socket.emitter.on("close", () => {
625
+ if (this.#socket === socket) this.#socket = void 0;
557
626
  this.#emitter.emit("close");
558
627
  });
559
- ws.emitter.on("error", (error) => {
560
- this.#emitter.emit("error", error);
561
- });
628
+ socket.emitter.on("error", (error) => this.#emitter.emit("error", error));
562
629
  }
563
630
  };
564
631
  //#endregion
@@ -605,20 +672,30 @@ function createScreenshotWriter() {
605
672
  var Browser = class {
606
673
  #emitter;
607
674
  #options;
675
+ #abort = new AbortController();
676
+ #cdpPort;
677
+ #cdpHost;
608
678
  #engine;
609
679
  #status = "idle";
610
680
  #connection;
681
+ #owned;
682
+ #endpoint;
611
683
  #client;
612
684
  #process;
613
- #cdpPort;
614
- #cdpHost;
615
685
  #contexts = [];
616
686
  #destroyed = false;
617
- #transportUnbind;
618
- #processUnbind;
619
- #lastPid;
687
+ #connecting;
688
+ #disconnecting;
689
+ #shutdown;
690
+ #transport;
691
+ #onTransportClose = () => this.#handleTransportLoss();
692
+ #onTransportError = () => this.#handleTransportLoss();
693
+ #onProcessExit = () => this.#handleProcessExit();
620
694
  constructor(options) {
621
- this.#emitter = new Emitter({ on: options?.on });
695
+ this.#emitter = new Emitter({
696
+ on: options?.on,
697
+ error: options?.error
698
+ });
622
699
  this.#options = options ?? {};
623
700
  this.#engine = this.#options.engine ?? (this.#options.executable !== void 0 ? parseBrowserEngine(this.#options.executable) ?? "chromium" : "chromium");
624
701
  this.#cdpPort = this.#options.cdp?.port ?? 9222;
@@ -637,63 +714,62 @@ var Browser = class {
637
714
  get connection() {
638
715
  return this.#connection;
639
716
  }
717
+ get owned() {
718
+ return this.#owned;
719
+ }
640
720
  get connected() {
641
721
  return this.#status === "connected";
642
722
  }
643
723
  get pid() {
644
- return this.#process?.pid ?? this.#lastPid;
724
+ return this.#process?.pid;
645
725
  }
646
726
  async discover() {
647
- const result = await this.#discoverCdp();
727
+ const result = await this.#discoverCDP();
648
728
  this.#emitter.emit("discover", result);
649
729
  return result;
650
730
  }
651
731
  async connect() {
732
+ if (this.#destroyed) throw new BrowserDestroyedError();
733
+ if (this.#disconnecting !== void 0) await this.#disconnecting;
652
734
  if (this.#destroyed) throw new BrowserDestroyedError();
653
735
  if (this.#status === "connected") return;
654
- this.#assertNotAborted();
655
- this.#status = "connecting";
736
+ const active = this.#connecting;
737
+ if (active !== void 0) {
738
+ await active;
739
+ return;
740
+ }
741
+ const attempt = this.#establish();
742
+ this.#connecting = attempt;
656
743
  try {
657
- const cdpEndpoint = this.#options.cdp?.endpoint;
658
- if (cdpEndpoint !== void 0) {
659
- await this.#connectCdp(cdpEndpoint);
660
- return;
661
- }
662
- if (this.#options.cdp?.discover ?? true) {
663
- this.#assertNotAborted();
664
- const discovery = await this.#raceAbort(this.#discoverCdp());
665
- if (discovery.found && discovery.endpoint !== void 0) {
666
- this.#engine = browserToEngine(discovery.browser ?? "");
667
- await this.#connectCdp(discovery.endpoint);
668
- return;
669
- }
670
- } else {
671
- this.#assertNotAborted();
672
- await this.#raceAbort(this.#assertPortFree());
673
- }
674
- this.#assertNotAborted();
675
- await this.#launch();
676
- } catch (thrown) {
677
- this.#status = "error";
678
- this.#emitter.emit("error", thrown);
679
- if (thrown instanceof BrowserConnectionError) throw thrown;
680
- throw new BrowserConnectionError(thrown instanceof Error ? thrown.message : String(thrown), { executable: this.#options.executable });
744
+ await attempt;
745
+ } finally {
746
+ if (this.#connecting === attempt) this.#connecting = void 0;
681
747
  }
682
748
  }
749
+ adopt() {
750
+ if (this.#destroyed) throw new BrowserDestroyedError();
751
+ if (this.#status !== "connected" || this.#client === void 0 || this.#endpoint === void 0) throw new BrowserNotConnectedError();
752
+ this.#owned = true;
753
+ }
683
754
  async disconnect() {
684
- if (this.#destroyed || this.#status !== "connected") return;
685
- if (this.#connection === "launch") throw new BrowserConnectionError("Cannot disconnect() an ephemeral launch — no persistent profile to reattach to; ephemeral launches must use destroy() to release it", { connection: this.#connection });
686
- this.#unbindTransport();
687
- this.#unbindProcess();
688
- const client = this.#client;
689
- if (client !== void 0) try {
690
- await client.close();
691
- } catch {}
692
- if (this.#process?.pid !== void 0) this.#lastPid = this.#process.pid;
693
- this.#process = void 0;
694
- this.#reset();
695
- this.#emitter.emit("disconnect");
696
- this.#emitter.emit("idle");
755
+ if (this.#destroyed) return;
756
+ const active = this.#disconnecting;
757
+ if (active !== void 0) {
758
+ await active;
759
+ return;
760
+ }
761
+ if (this.#connecting !== void 0) {
762
+ await this.#connecting.catch(() => void 0);
763
+ if (this.#destroyed) return;
764
+ }
765
+ if (this.#status !== "connected" || this.#client === void 0) return;
766
+ const attempt = this.#detach();
767
+ this.#disconnecting = attempt;
768
+ try {
769
+ await attempt;
770
+ } finally {
771
+ if (this.#disconnecting === attempt) this.#disconnecting = void 0;
772
+ }
697
773
  }
698
774
  context(index) {
699
775
  const i = index ?? 0;
@@ -705,237 +781,218 @@ var Browser = class {
705
781
  }
706
782
  async create(options) {
707
783
  if (this.#destroyed) throw new BrowserDestroyedError();
708
- if (this.#status !== "connected" || this.#client === void 0) throw new BrowserNotConnectedError();
709
- let ctx = this.#contexts[0];
710
- if (ctx === void 0) {
711
- ctx = new BrowserContext(this.#client, void 0, this.#options.viewport, createScreenshotWriter());
712
- this.#contexts.push(ctx);
784
+ const client = this.#client;
785
+ if (this.#status !== "connected" || client === void 0) throw new BrowserNotConnectedError();
786
+ let context = this.#contexts[0];
787
+ if (context === void 0) {
788
+ context = new BrowserContext(client, void 0, this.#options.viewport, createScreenshotWriter());
789
+ this.#contexts.push(context);
713
790
  }
714
- const page = await ctx.create(options);
791
+ const page = await context.create(options);
715
792
  this.#emitter.emit("page", page);
716
793
  return page;
717
794
  }
718
- async destroy() {
719
- if (this.#destroyed) return;
795
+ destroy() {
796
+ const active = this.#shutdown;
797
+ if (active !== void 0) return active;
798
+ if (this.#destroyed) return Promise.resolve();
720
799
  this.#destroyed = true;
721
- try {
722
- this.#unbindTransport();
723
- this.#unbindProcess();
724
- if (this.#process !== void 0) for (const ctx of this.#contexts) try {
725
- await ctx.close();
726
- } catch {}
727
- this.#contexts = [];
728
- await this.#terminate(this.#process);
729
- this.#process = void 0;
730
- if (this.#client !== void 0) {
731
- try {
732
- await this.#client.close();
733
- } catch {}
734
- this.#client = void 0;
735
- }
736
- } finally {
737
- this.#status = "disconnected";
738
- this.#connection = void 0;
739
- this.#lastPid = void 0;
740
- this.#emitter.emit("destroy");
741
- this.#emitter.destroy();
742
- }
800
+ this.#abort.abort();
801
+ const shutdown = this.#destroyResources();
802
+ this.#shutdown = shutdown;
803
+ return shutdown;
743
804
  }
744
- /**
745
- * Gracefully shut down the remote browser, whether this instance owns its
746
- * process or merely attached to it via CDP.
747
- *
748
- * @remarks
749
- * Sends CDP `Browser.close` best-effort; on an owned browser also awaits
750
- * the process's exit (bounded by the kill-escalation grace period,
751
- * escalating to a kill only if it does not exit in time). Ends in the
752
- * same local-cleanup state as `destroy()`.
753
- */
754
- async close() {
755
- if (this.#destroyed) return;
805
+ close() {
806
+ const active = this.#shutdown;
807
+ if (active !== void 0) return active;
808
+ if (this.#destroyed) return Promise.resolve();
756
809
  this.#destroyed = true;
810
+ this.#abort.abort();
811
+ const shutdown = this.#closeResources();
812
+ this.#shutdown = shutdown;
813
+ return shutdown;
814
+ }
815
+ async #establish() {
816
+ this.#assertNotAborted();
817
+ this.#status = "connecting";
757
818
  try {
758
- this.#unbindTransport();
759
- this.#unbindProcess();
760
- const client = this.#client;
761
- if (client !== void 0) try {
762
- await client.send("Browser.close");
763
- } catch {}
764
- const process = this.#process;
765
- if (process !== void 0) {
766
- const exited = new Promise((resolve) => {
767
- process.once("exit", () => resolve());
768
- });
769
- let timer;
770
- const timedOut = await Promise.race([exited.then(() => false), new Promise((resolve) => {
771
- timer = setTimeout(() => resolve(true), BROWSER_KILL_GRACE_MS);
772
- })]);
773
- if (timer !== void 0) clearTimeout(timer);
774
- if (timedOut) await this.#terminate(process);
775
- this.#process = void 0;
819
+ if (this.#owned === true && this.#endpoint !== void 0) {
820
+ await this.#connectCDP(this.#endpoint);
821
+ return;
776
822
  }
777
- for (const ctx of this.#contexts) try {
778
- await ctx.close();
779
- } catch {}
780
- this.#contexts = [];
781
- if (client !== void 0) {
782
- try {
783
- await client.close();
784
- } catch {}
785
- this.#client = void 0;
823
+ const endpoint = this.#options.cdp?.endpoint;
824
+ if (endpoint !== void 0) {
825
+ await this.#connectCDP(endpoint);
826
+ return;
786
827
  }
787
- } finally {
788
- this.#status = "disconnected";
789
- this.#connection = void 0;
790
- this.#lastPid = void 0;
791
- this.#emitter.emit("destroy");
792
- this.#emitter.destroy();
828
+ if (this.#options.cdp?.discover ?? true) {
829
+ const discovery = await this.#raceAbort(this.#discoverCDP(this.#signal()));
830
+ if (discovery.found && discovery.endpoint !== void 0) {
831
+ this.#engine = browserToEngine(discovery.browser);
832
+ await this.#connectCDP(discovery.endpoint);
833
+ return;
834
+ }
835
+ } else await this.#raceAbort(this.#assertPortFree());
836
+ this.#assertNotAborted();
837
+ await this.#launch();
838
+ } catch (error) {
839
+ if (this.#destroyed) throw new BrowserDestroyedError();
840
+ this.#status = "error";
841
+ this.#emitter.emit("error", error);
842
+ if (error instanceof BrowserConnectionError) throw error;
843
+ throw new BrowserConnectionError(error instanceof Error ? error.message : String(error), { executable: this.#options.executable });
844
+ }
845
+ }
846
+ async #detach() {
847
+ this.#status = "disconnected";
848
+ this.#connection = void 0;
849
+ this.#unbindTransport();
850
+ const client = this.#client;
851
+ this.#client = void 0;
852
+ await this.#destroyContexts();
853
+ this.#contexts = [];
854
+ if (this.#owned !== true) {
855
+ this.#owned = void 0;
856
+ this.#endpoint = void 0;
793
857
  }
858
+ await this.#closeClient(client);
859
+ if (this.#destroyed) return;
860
+ this.#emitter.emit("disconnect");
861
+ this.#emitter.emit("idle");
794
862
  }
795
863
  #assertNotAborted() {
796
- if (this.#options.signal?.aborted) throw new BrowserConnectionError("Connection aborted");
864
+ if (this.#signal().aborted) throw new BrowserConnectionError("Connection aborted");
865
+ }
866
+ #signal() {
867
+ const external = this.#options.signal;
868
+ return external === void 0 ? this.#abort.signal : AbortSignal.any([this.#abort.signal, external]);
797
869
  }
798
- /**
799
- * Race a promise against the connection's external AbortSignal (if any),
800
- * rejecting promptly with a coded BrowserConnectionError when it fires.
801
- */
802
870
  async #raceAbort(promise) {
803
- const signal = this.#options.signal;
804
- if (signal === void 0) return promise;
871
+ const signal = this.#signal();
805
872
  if (signal.aborted) throw new BrowserConnectionError("Connection aborted");
806
- let abortWon = false;
807
- return await new Promise((resolve, reject) => {
808
- const onAbort = () => {
809
- abortWon = true;
810
- cleanup();
811
- reject(new BrowserConnectionError("Connection aborted"));
812
- };
813
- const cleanup = () => signal.removeEventListener("abort", onAbort);
814
- signal.addEventListener("abort", onAbort, { once: true });
815
- promise.then((value) => {
816
- cleanup();
817
- resolve(value);
818
- }, (error) => {
819
- cleanup();
820
- reject(error);
821
- });
822
- }).catch((error) => {
823
- if (abortWon) promise.catch(() => void 0);
824
- throw error;
873
+ const aborted = Promise.withResolvers();
874
+ const listener = addAbortListener(signal, () => {
875
+ aborted.reject(new BrowserConnectionError("Connection aborted"));
825
876
  });
877
+ try {
878
+ return await Promise.race([promise, aborted.promise]);
879
+ } finally {
880
+ listener[Symbol.dispose]();
881
+ promise.catch(() => void 0);
882
+ }
826
883
  }
827
- #reset() {
828
- this.#contexts = [];
829
- this.#status = "disconnected";
830
- this.#connection = void 0;
831
- this.#client = void 0;
832
- }
833
- /**
834
- * Handle the owned process exiting on its own (not via destroy()/close()).
835
- * The process is already gone — no kill is attempted. Cleans up orphaned
836
- * state so the next connect() call can start fresh, and emits a coded
837
- * `error` (cause: process exit) before `disconnect`.
838
- */
839
884
  #handleProcessExit() {
840
885
  if (this.#destroyed) return;
841
- if (this.#status !== "connected") return;
886
+ const connected = this.#status === "connected";
887
+ const client = this.#client;
888
+ const contexts = this.#contexts;
842
889
  this.#unbindTransport();
843
890
  this.#unbindProcess();
844
- const client = this.#client;
845
891
  this.#process = void 0;
846
- this.#lastPid = void 0;
847
- this.#reset();
848
- if (client !== void 0) client.close().catch(() => void 0);
849
- this.#emitter.emit("error", new BrowserConnectionError("The browser process exited unexpectedly", { cause: "process-exit" }));
850
- this.#emitter.emit("disconnect");
851
- this.#emitter.emit("idle");
892
+ this.#client = void 0;
893
+ this.#contexts = [];
894
+ this.#connection = void 0;
895
+ this.#owned = void 0;
896
+ this.#endpoint = void 0;
897
+ if (connected) this.#status = "disconnected";
898
+ this.#destroyContextList(contexts);
899
+ this.#closeClient(client);
900
+ this.#emitter.emit("error", new BrowserConnectionError("The browser process exited unexpectedly", { cause: BROWSER_PROCESS_EXIT_CAUSE }));
901
+ if (connected) {
902
+ this.#emitter.emit("disconnect");
903
+ this.#emitter.emit("idle");
904
+ }
852
905
  }
853
- /**
854
- * Handle a transport close/error while the owned process (if any) is
855
- * still alive, or the connection is CDP-attached. Never kills the
856
- * process — only the local client is reset, so the SAME instance can
857
- * reconnect (e.g. rediscover on the port and reattach). Emits a coded
858
- * `error` (cause: connection loss) before `disconnect`.
859
- */
860
- #handleTransportLoss(deferred = false) {
861
- if (this.#destroyed) return;
862
- if (this.#status !== "connected") return;
906
+ #handleTransportLoss() {
907
+ if (this.#destroyed || this.#status !== "connected") return;
863
908
  const process = this.#process;
864
909
  if (process !== void 0 && (process.exitCode !== null || process.signalCode !== null)) {
865
910
  this.#handleProcessExit();
866
911
  return;
867
912
  }
868
- if (process !== void 0 && !deferred) {
869
- setTimeout(() => this.#handleTransportLoss(true), 50);
913
+ if (process !== void 0) {
914
+ setTimeout(() => this.#confirmTransportLoss(), 50);
915
+ return;
916
+ }
917
+ this.#confirmTransportLoss();
918
+ }
919
+ #confirmTransportLoss() {
920
+ if (this.#destroyed || this.#status !== "connected") return;
921
+ const process = this.#process;
922
+ if (process !== void 0 && (process.exitCode !== null || process.signalCode !== null)) {
923
+ this.#handleProcessExit();
870
924
  return;
871
925
  }
872
- if (process !== void 0 && process.pid !== void 0) try {
926
+ if (process?.pid !== void 0) try {
873
927
  process.kill(0);
874
928
  } catch {
875
929
  this.#handleProcessExit();
876
930
  return;
877
931
  }
878
- this.#unbindTransport();
879
932
  const client = this.#client;
880
- this.#reset();
881
- if (client !== void 0) client.close().catch(() => void 0);
882
- this.#emitter.emit("error", new BrowserConnectionError("The CDP transport connection was lost", { cause: "connection-loss" }));
933
+ const owned = this.#owned === true;
934
+ const contexts = this.#contexts;
935
+ this.#unbindTransport();
936
+ this.#client = void 0;
937
+ this.#contexts = [];
938
+ this.#connection = void 0;
939
+ this.#status = "disconnected";
940
+ if (!owned) {
941
+ this.#owned = void 0;
942
+ this.#endpoint = void 0;
943
+ }
944
+ this.#destroyContextList(contexts);
945
+ this.#closeClient(client);
946
+ this.#emitter.emit("error", new BrowserConnectionError("The CDP transport connection was lost", { cause: BROWSER_TRANSPORT_LOSS_CAUSE }));
883
947
  this.#emitter.emit("disconnect");
884
948
  this.#emitter.emit("idle");
885
949
  }
886
- /** Subscribe to the CDP transport's close/error events for deterministic transport-loss detection. */
887
950
  #bindTransport(transport) {
888
- const onClose = () => this.#handleTransportLoss();
889
- const onError = () => this.#handleTransportLoss();
890
- transport.emitter.on("close", onClose);
891
- transport.emitter.on("error", onError);
892
- this.#transportUnbind = () => {
893
- transport.emitter.off("close", onClose);
894
- transport.emitter.off("error", onError);
895
- };
951
+ this.#unbindTransport();
952
+ this.#transport = transport;
953
+ transport.emitter.on("close", this.#onTransportClose);
954
+ transport.emitter.on("error", this.#onTransportError);
896
955
  }
897
956
  #unbindTransport() {
898
- this.#transportUnbind?.();
899
- this.#transportUnbind = void 0;
957
+ const transport = this.#transport;
958
+ if (transport === void 0) return;
959
+ transport.emitter.off("close", this.#onTransportClose);
960
+ transport.emitter.off("error", this.#onTransportError);
961
+ this.#transport = void 0;
900
962
  }
901
- /** Subscribe to a launched process's exit event for deterministic process-exit detection. */
902
963
  #bindProcess(process) {
903
- const onExit = () => this.#handleProcessExit();
904
- process.once("exit", onExit);
905
- this.#processUnbind = () => process.off("exit", onExit);
964
+ this.#unbindProcess();
965
+ process.once("exit", this.#onProcessExit);
966
+ if (process.exitCode !== null || process.signalCode !== null) queueMicrotask(this.#onProcessExit);
906
967
  }
907
968
  #unbindProcess() {
908
- this.#processUnbind?.();
909
- this.#processUnbind = void 0;
969
+ this.#process?.off("exit", this.#onProcessExit);
910
970
  }
911
971
  #timeout() {
912
972
  return this.#options.timeout ?? BROWSER_DEFAULT_TIMEOUT_MS;
913
973
  }
914
- async #discoverCdp() {
915
- const port = this.#cdpPort;
916
- const url = `${BROWSER_CDP_PROTOCOL}://${this.#cdpHost}:${port}${BROWSER_CDP_VERSION_PATH}`;
917
- const controller = new AbortController();
918
- const timer = setTimeout(() => controller.abort(), this.#timeout());
974
+ async #discoverCDP(signal) {
975
+ const url = `${BROWSER_CDP_PROTOCOL}://${this.#cdpHost}:${this.#cdpPort}${BROWSER_CDP_VERSION_PATH}`;
976
+ const requestSignal = signal === void 0 ? AbortSignal.timeout(this.#timeout()) : AbortSignal.any([signal, AbortSignal.timeout(this.#timeout())]);
919
977
  try {
920
- const response = await fetch(url, { signal: controller.signal });
921
- if (!response.ok) return this.#notFoundResult();
978
+ const response = await fetch(url, { signal: requestSignal });
979
+ if (!response.ok) return this.#notFound();
922
980
  const info = await response.json();
923
- if (!isRecord(info)) return this.#notFoundResult();
981
+ if (!isRecord(info)) return this.#notFound();
924
982
  const endpoint = isString(info["webSocketDebuggerUrl"]) ? info["webSocketDebuggerUrl"] : void 0;
925
- const browserName = isString(info["Browser"]) ? info["Browser"] : void 0;
983
+ const browser = isString(info["Browser"]) ? info["Browser"] : void 0;
926
984
  return {
927
985
  found: endpoint !== void 0,
928
986
  endpoint,
929
- browser: browserName,
987
+ browser,
930
988
  connection: endpoint !== void 0 ? "cdp" : void 0
931
989
  };
932
- } catch {
933
- return this.#notFoundResult();
934
- } finally {
935
- clearTimeout(timer);
990
+ } catch (error) {
991
+ if (signal?.aborted === true) throw error;
992
+ return this.#notFound();
936
993
  }
937
994
  }
938
- #notFoundResult() {
995
+ #notFound() {
939
996
  return {
940
997
  found: false,
941
998
  endpoint: void 0,
@@ -943,31 +1000,21 @@ var Browser = class {
943
1000
  connection: void 0
944
1001
  };
945
1002
  }
946
- /**
947
- * Reject when something is already listening on the configured CDP port —
948
- * used ahead of a `discover: false` launch, so a caller demanding a fresh
949
- * browser never silently attaches to a stranger already on that port.
950
- */
951
1003
  async #assertPortFree() {
952
1004
  if (await this.#probePort()) throw new BrowserConnectionError(`Port ${this.#cdpPort} on ${this.#cdpHost} is already occupied by another CDP endpoint`, {
953
1005
  port: this.#cdpPort,
954
1006
  host: this.#cdpHost
955
1007
  });
956
1008
  }
957
- /** Very short probe of the CDP version endpoint — just enough to detect an occupied port, not full discovery. */
958
1009
  async #probePort() {
959
1010
  const url = `${BROWSER_CDP_PROTOCOL}://${this.#cdpHost}:${this.#cdpPort}${BROWSER_CDP_VERSION_PATH}`;
960
- const controller = new AbortController();
961
- const timer = setTimeout(() => controller.abort(), 200);
962
1011
  try {
963
- return (await fetch(url, { signal: controller.signal })).ok;
1012
+ return (await fetch(url, { signal: AbortSignal.timeout(200) })).ok;
964
1013
  } catch {
965
1014
  return false;
966
- } finally {
967
- clearTimeout(timer);
968
1015
  }
969
1016
  }
970
- async #connectCdp(endpoint) {
1017
+ async #connectCDP(endpoint) {
971
1018
  const transport = createCDPTransport({
972
1019
  url: endpoint,
973
1020
  timeout: this.#timeout()
@@ -976,18 +1023,27 @@ var Browser = class {
976
1023
  transport,
977
1024
  timeout: this.#timeout()
978
1025
  });
1026
+ const retained = this.#owned === true && this.#endpoint === endpoint;
979
1027
  try {
980
1028
  await this.#raceAbort(client.connect());
981
1029
  this.#client = client;
982
- this.#connection = "cdp";
1030
+ this.#endpoint = endpoint;
1031
+ this.#connection = retained ? this.#retainedConnection() : "cdp";
1032
+ this.#owned = retained;
983
1033
  this.#status = "connected";
984
1034
  this.#bindTransport(transport);
985
1035
  await this.#syncContexts();
986
- this.#emitter.emit("connect", "cdp");
1036
+ this.#emitter.emit("connect", this.#connection);
987
1037
  } catch (error) {
988
- try {
989
- await client.close();
990
- } catch {}
1038
+ this.#unbindTransport();
1039
+ if (this.#client === client) this.#client = void 0;
1040
+ this.#contexts = [];
1041
+ this.#connection = void 0;
1042
+ if (!retained) {
1043
+ this.#owned = void 0;
1044
+ this.#endpoint = void 0;
1045
+ }
1046
+ await this.#closeClient(client);
991
1047
  throw error;
992
1048
  }
993
1049
  }
@@ -1005,18 +1061,16 @@ var Browser = class {
1005
1061
  executable = found?.executable;
1006
1062
  resolvedEngine = found?.engine;
1007
1063
  }
1008
- if (executable === void 0) throw new BrowserConnectionError("No Chromium browser found. Install Chrome, Edge, or Chromium.", requestedEngine !== void 0 ? { engine: requestedEngine } : void 0);
1064
+ if (executable === void 0) throw new BrowserConnectionError("No Chromium browser found. Install Chrome, Edge, or Chromium.", requestedEngine === void 0 ? void 0 : { engine: requestedEngine });
1009
1065
  this.#engine = resolvedEngine ?? "chromium";
1010
- const headless = this.#options.headless ?? true;
1011
1066
  const profile = this.#options.profile;
1012
- const extra = this.#options.args;
1013
- const process = launchBrowserProcess(executable, this.#cdpPort, headless, profile, extra);
1067
+ const process = launchBrowserProcess(executable, this.#cdpPort, this.#options.headless ?? true, profile, this.#options.args);
1014
1068
  this.#process = process;
1015
- let transport;
1016
1069
  let client;
1017
1070
  try {
1018
- transport = createCDPTransport({
1019
- url: await this.#raceAbort(this.#waitForLaunch(process, executable, extra)),
1071
+ const endpoint = await this.#waitForLaunch(process, executable, this.#options.args);
1072
+ const transport = createCDPTransport({
1073
+ url: endpoint,
1020
1074
  timeout: this.#timeout()
1021
1075
  });
1022
1076
  client = new CDPClient({
@@ -1024,20 +1078,26 @@ var Browser = class {
1024
1078
  timeout: this.#timeout()
1025
1079
  });
1026
1080
  await this.#raceAbort(client.connect());
1081
+ const connection = profile === void 0 ? "launch" : "persistent";
1027
1082
  this.#client = client;
1028
- this.#connection = profile !== void 0 ? "persistent" : "launch";
1083
+ this.#endpoint = endpoint;
1084
+ this.#connection = connection;
1085
+ this.#owned = true;
1029
1086
  this.#status = "connected";
1030
1087
  this.#bindTransport(transport);
1031
1088
  this.#bindProcess(process);
1032
1089
  await this.#syncContexts();
1033
1090
  this.#emitter.emit("launch", this.#engine);
1034
- this.#emitter.emit("connect", this.#connection);
1091
+ this.#emitter.emit("connect", connection);
1035
1092
  } catch (error) {
1036
- if (client !== void 0) try {
1037
- await client.close();
1038
- } catch {}
1093
+ this.#unbindTransport();
1094
+ this.#unbindProcess();
1095
+ await this.#closeClient(client);
1039
1096
  await this.#terminate(process);
1040
1097
  this.#process = void 0;
1098
+ this.#client = void 0;
1099
+ this.#endpoint = void 0;
1100
+ this.#owned = void 0;
1041
1101
  throw error;
1042
1102
  }
1043
1103
  }
@@ -1046,75 +1106,191 @@ var Browser = class {
1046
1106
  executable,
1047
1107
  args
1048
1108
  };
1049
- return await new Promise((resolve, reject) => {
1050
- let settled = false;
1051
- const finish = (callback) => {
1052
- if (settled) return;
1053
- settled = true;
1054
- process.off("error", onError);
1055
- process.off("exit", onExit);
1056
- callback();
1057
- };
1058
- const onError = (error) => {
1059
- finish(() => reject(new BrowserConnectionError(error.message, context)));
1060
- };
1061
- const onExit = (code, signal) => {
1062
- finish(() => reject(new BrowserConnectionError(this.#formatLaunchExit(code, signal), context)));
1063
- };
1064
- process.once("error", onError);
1065
- process.once("exit", onExit);
1066
- waitForCdpReady(this.#cdpPort, this.#timeout(), this.#cdpHost).then((wsUrl) => {
1067
- finish(() => resolve(wsUrl));
1068
- }, (error) => {
1069
- const message = error instanceof Error ? error.message : String(error);
1070
- finish(() => reject(new BrowserConnectionError(message, context)));
1071
- });
1109
+ const controller = new AbortController();
1110
+ const signal = AbortSignal.any([controller.signal, this.#signal()]);
1111
+ const ready = waitForCDPReady(this.#cdpPort, this.#timeout(), this.#cdpHost, signal);
1112
+ const exited = once(process, "exit", { signal }).then((values) => {
1113
+ const code = typeof values[0] === "number" ? values[0] : null;
1114
+ const exitSignal = typeof values[1] === "string" ? values[1] : null;
1115
+ throw new BrowserConnectionError(this.#formatLaunchExit(code, exitSignal), context);
1072
1116
  });
1117
+ try {
1118
+ return await Promise.race([ready, exited]);
1119
+ } catch (error) {
1120
+ if (error instanceof BrowserConnectionError) throw error;
1121
+ if (this.#signal().aborted) throw new BrowserConnectionError("Connection aborted", context);
1122
+ throw new BrowserConnectionError(error instanceof Error ? error.message : String(error), context);
1123
+ } finally {
1124
+ controller.abort();
1125
+ ready.catch(() => void 0);
1126
+ exited.catch(() => void 0);
1127
+ }
1073
1128
  }
1074
1129
  #formatLaunchExit(code, signal) {
1075
1130
  if (signal !== null) return `Browser process exited before CDP became ready (signal: ${signal})`;
1076
1131
  if (code !== null) return `Browser process exited before CDP became ready (code: ${code})`;
1077
1132
  return "Browser process exited before CDP became ready";
1078
1133
  }
1134
+ #retainedConnection() {
1135
+ if (this.#process === void 0) return "cdp";
1136
+ return this.#options.profile === void 0 ? "launch" : "persistent";
1137
+ }
1079
1138
  async #syncContexts() {
1080
- if (this.#client === void 0) return;
1081
- const pageTargets = (await fetchCdpTargets(this.#cdpPort, this.#timeout(), this.#cdpHost)).filter((t) => t.type === "page");
1082
- if (pageTargets.length > 0 && this.#contexts.length === 0) {
1083
- const ctx = new BrowserContext(this.#client, void 0, this.#options.viewport, createScreenshotWriter());
1084
- await ctx.sync(pageTargets);
1085
- this.#contexts.push(ctx);
1139
+ const client = this.#client;
1140
+ if (client === void 0) return;
1141
+ let result;
1142
+ try {
1143
+ result = await client.send("Target.getTargets");
1144
+ } catch {
1145
+ return;
1146
+ }
1147
+ if (!isRecord(result) || !Array.isArray(result["targetInfos"])) return;
1148
+ const pages = [];
1149
+ for (const target of result["targetInfos"]) {
1150
+ if (!isRecord(target) || !isString(target["targetId"]) || target["type"] !== "page" || !isString(target["title"]) || !isString(target["url"])) continue;
1151
+ pages.push({
1152
+ id: target["targetId"],
1153
+ type: target["type"],
1154
+ title: target["title"],
1155
+ url: target["url"]
1156
+ });
1086
1157
  }
1158
+ if (pages.length === 0 || this.#contexts.length > 0) return;
1159
+ const context = new BrowserContext(client, void 0, this.#options.viewport, createScreenshotWriter());
1160
+ await context.sync(pages);
1161
+ this.#contexts.push(context);
1087
1162
  }
1088
- /**
1089
- * Terminate a browser process, escalating from SIGTERM to SIGKILL if it
1090
- * does not exit within the grace period. Tolerates a process that has
1091
- * already exited (ESRCH).
1092
- */
1093
- async #terminate(process) {
1094
- if (process === void 0) return;
1163
+ async #destroyResources() {
1164
+ try {
1165
+ await this.#settle();
1166
+ this.#unbindTransport();
1167
+ this.#unbindProcess();
1168
+ const client = this.#client;
1169
+ if (this.#owned === true) {
1170
+ await this.#closeContexts();
1171
+ if (this.#process === void 0) await this.#closeRemote(client);
1172
+ } else await this.#destroyContexts();
1173
+ this.#contexts = [];
1174
+ await this.#terminate(this.#process);
1175
+ this.#process = void 0;
1176
+ await this.#closeClient(client);
1177
+ this.#client = void 0;
1178
+ } finally {
1179
+ this.#finish();
1180
+ }
1181
+ }
1182
+ async #closeResources() {
1183
+ try {
1184
+ await this.#settle();
1185
+ this.#unbindTransport();
1186
+ this.#unbindProcess();
1187
+ const client = this.#client;
1188
+ await this.#closeContexts();
1189
+ this.#contexts = [];
1190
+ await this.#closeRemote(client);
1191
+ const process = this.#process;
1192
+ if (process !== void 0) {
1193
+ if (!await this.#waitForProcessWithin(process, 3e3)) await this.#terminate(process);
1194
+ }
1195
+ this.#process = void 0;
1196
+ await this.#closeClient(client);
1197
+ this.#client = void 0;
1198
+ } finally {
1199
+ this.#finish();
1200
+ }
1201
+ }
1202
+ async #settle() {
1203
+ await this.#connecting?.catch(() => void 0);
1204
+ await this.#disconnecting?.catch(() => void 0);
1205
+ }
1206
+ async #closeRemote(client) {
1207
+ let remote = client;
1208
+ let temporary = false;
1209
+ if (remote === void 0 && this.#owned === true && this.#endpoint !== void 0) {
1210
+ remote = new CDPClient({
1211
+ transport: createCDPTransport({
1212
+ url: this.#endpoint,
1213
+ timeout: this.#timeout()
1214
+ }),
1215
+ timeout: this.#timeout()
1216
+ });
1217
+ try {
1218
+ await remote.connect();
1219
+ temporary = true;
1220
+ } catch {
1221
+ await this.#closeClient(remote);
1222
+ return;
1223
+ }
1224
+ }
1225
+ if (remote !== void 0) try {
1226
+ await remote.send("Browser.close");
1227
+ } catch {}
1228
+ if (temporary) await this.#closeClient(remote);
1229
+ }
1230
+ async #closeContexts() {
1231
+ for (const context of this.#contexts) try {
1232
+ await context.close();
1233
+ } catch {}
1234
+ }
1235
+ async #destroyContexts() {
1236
+ await this.#destroyContextList(this.#contexts);
1237
+ }
1238
+ async #destroyContextList(contexts) {
1239
+ for (const context of contexts) try {
1240
+ await context.destroy();
1241
+ } catch {}
1242
+ }
1243
+ async #closeClient(client) {
1244
+ if (client === void 0) return;
1245
+ try {
1246
+ await client.close();
1247
+ } catch {}
1248
+ }
1249
+ async #waitForProcess(process) {
1095
1250
  if (process.exitCode !== null || process.signalCode !== null) return;
1096
- const exited = new Promise((resolve) => {
1097
- process.once("exit", () => resolve());
1098
- });
1251
+ await once(process, "exit");
1252
+ }
1253
+ async #waitForProcessWithin(process, timeout) {
1254
+ if (process.exitCode !== null || process.signalCode !== null) return true;
1255
+ const signal = AbortSignal.timeout(timeout);
1256
+ try {
1257
+ await once(process, "exit", { signal });
1258
+ return true;
1259
+ } catch (error) {
1260
+ if (signal.aborted) return process.exitCode !== null || process.signalCode !== null;
1261
+ throw error;
1262
+ }
1263
+ }
1264
+ async #terminate(process) {
1265
+ if (process === void 0 || process.exitCode !== null || process.signalCode !== null) return;
1099
1266
  try {
1100
1267
  process.kill("SIGTERM");
1101
1268
  } catch {
1102
1269
  return;
1103
1270
  }
1104
- let timer;
1105
- const timedOut = await Promise.race([exited.then(() => false), new Promise((resolve) => {
1106
- timer = setTimeout(() => resolve(true), BROWSER_KILL_GRACE_MS);
1107
- })]);
1108
- if (timer !== void 0) clearTimeout(timer);
1109
- if (timedOut) {
1110
- try {
1111
- process.kill("SIGKILL");
1112
- } catch {}
1113
- await exited;
1271
+ if (await this.#waitForProcessWithin(process, 3e3)) return;
1272
+ try {
1273
+ process.kill("SIGKILL");
1274
+ } catch {
1275
+ return;
1114
1276
  }
1277
+ await this.#waitForProcess(process);
1278
+ }
1279
+ #finish() {
1280
+ this.#unbindTransport();
1281
+ this.#unbindProcess();
1282
+ this.#status = "disconnected";
1283
+ this.#connection = void 0;
1284
+ this.#owned = void 0;
1285
+ this.#endpoint = void 0;
1286
+ this.#client = void 0;
1287
+ this.#process = void 0;
1288
+ this.#contexts = [];
1289
+ this.#emitter.emit("destroy");
1290
+ this.#emitter.destroy();
1115
1291
  }
1116
1292
  };
1117
1293
  //#endregion
1118
- export { BROWSER_CDP_LIST_PATH, BROWSER_CDP_PROTOCOL, BROWSER_CDP_VERSION_PATH, BROWSER_DEFAULT_CDP_PORT, BROWSER_DEFAULT_HOST, BROWSER_ENGINE_HINTS, BROWSER_ENV_PATH_KEYS, BROWSER_EXECUTABLE_NAMES, BROWSER_EXECUTABLE_PATHS, BROWSER_HEADLESS_ARG, BROWSER_KILL_GRACE_MS, BROWSER_LAUNCH_ARGS, BROWSER_NOT_FOUND_RESULT, BROWSER_PORT_PROBE_TIMEOUT_MS, BROWSER_STORE_CACHE_DIRS, BROWSER_STORE_DEFAULT_DIRS, BROWSER_STORE_ENV_KEY, BROWSER_STORE_GLOBS, BROWSER_STORE_LINK_NAME, BROWSER_TRANSPORT_LOSS_DEFER_MS, BROWSER_WINDOWS_ROOT_FALLBACKS, BROWSER_WINDOWS_SUFFIXES, Browser, BrowserConnectionError, BrowserDestroyedError, BrowserNotConnectedError, WebSocketCDPTransport, browserToEngine, createBrowser, createCDPTransport, createScreenshotWriter, defaultInstallPaths, defaultStoreBases, fetchCdpTargets, findAllEnvOverrides, findAllInStore, findAllInstallPaths, findEnvOverride, findInStore, findInstallPath, findSystemBrowser, findSystemBrowsers, isBrowserConnectionError, isBrowserDestroyedError, isBrowserNotConnectedError, launchBrowserProcess, normalizeExecutablePath, parseBrowserEngine, probeAllPathNames, probePathNames, waitForCdpReady, windowsRoots };
1294
+ export { BROWSER_CDP_LIST_PATH, BROWSER_CDP_PROTOCOL, BROWSER_CDP_VERSION_PATH, BROWSER_DEFAULT_CDP_PORT, BROWSER_DEFAULT_HOST, BROWSER_ENGINE_HINTS, BROWSER_ENV_PATH_KEYS, BROWSER_EXECUTABLE_NAMES, BROWSER_EXECUTABLE_PATHS, BROWSER_HEADLESS_ARG, BROWSER_KILL_GRACE_MS, BROWSER_LAUNCH_ARGS, BROWSER_PORT_PROBE_TIMEOUT_MS, BROWSER_PROCESS_EXIT_CAUSE, BROWSER_STORE_CACHE_DIRS, BROWSER_STORE_DEFAULT_DIRS, BROWSER_STORE_ENV_KEY, BROWSER_STORE_GLOBS, BROWSER_STORE_LINK_NAME, BROWSER_TRANSPORT_LOSS_CAUSE, BROWSER_TRANSPORT_LOSS_DEFER_MS, BROWSER_WINDOWS_ROOT_FALLBACKS, BROWSER_WINDOWS_SUFFIXES, Browser, BrowserConnectionError, BrowserDestroyedError, BrowserNotConnectedError, WebSocketCDPTransport, browserToEngine, createBrowser, createCDPTransport, createScreenshotWriter, defaultInstallPaths, defaultStoreBases, fetchCDPTargets, findAllEnvOverrides, findAllInStore, findAllInstallPaths, findEnvOverride, findInStore, findInstallPath, findSystemBrowser, findSystemBrowsers, isBrowserConnectionError, isBrowserDestroyedError, isBrowserNotConnectedError, launchBrowserProcess, normalizeExecutablePath, parseBrowserEngine, probeAllPathNames, probePathNames, waitForCDPReady, windowsRoots };
1119
1295
 
1120
1296
  //# sourceMappingURL=index.js.map