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