@orkestrel/browser 0.0.3 → 0.0.6

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.
@@ -1,13 +1,17 @@
1
- import { BROWSER_DEFAULT_TIMEOUT_MS, BROWSER_WAIT_POLL_INTERVAL_MS, BrowserContext, BrowserError, CDPClient } from "../core/index.js";
1
+ import { BROWSER_DEFAULT_TIMEOUT_MS, BROWSER_WAIT_POLL_INTERVAL_MS, BrowserContext, BrowserError, CDPClient, validateBrowserContextOptions } from "../core/index.js";
2
+ import { attempt, isArray, isError, isInstance, isInteger, isRecord, isString } from "@orkestrel/contract";
2
3
  import { existsSync, globSync } from "node:fs";
3
- import { dirname, posix, win32 } from "node:path";
4
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { basename, dirname, join, posix, resolve, win32 } from "node:path";
4
7
  import { spawn, spawnSync } from "node:child_process";
5
- import { isRecord, isString } from "@orkestrel/contract";
8
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
6
9
  import { randomBytes } from "node:crypto";
7
10
  import { request } from "node:http";
11
+ import { request as request$1 } from "node:https";
8
12
  import { Emitter } from "@orkestrel/emitter";
9
- import { WEBSOCKET_READY_OPEN, WEBSOCKET_VERSION, computeWebSocketAccept, createNodeWebSocket } from "@orkestrel/websocket";
10
- import { mkdir, writeFile } from "node:fs/promises";
13
+ import { WEBSOCKET_READY_CLOSED, WEBSOCKET_READY_OPEN, WEBSOCKET_VERSION, computeWebSocketAccept, createNodeWebSocket } from "@orkestrel/websocket";
14
+ import { addAbortListener, once } from "node:events";
11
15
  //#region src/server/constants.ts
12
16
  /** Default CDP port probed for an existing browser and used for launches. */
13
17
  var BROWSER_DEFAULT_CDP_PORT = 9222;
@@ -19,23 +23,22 @@ var BROWSER_CDP_PROTOCOL = "http";
19
23
  var BROWSER_CDP_VERSION_PATH = "/json/version";
20
24
  /** Path appended to the CDP host to list open targets (pages, workers, etc). */
21
25
  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
26
  /** Flags always passed to a launched browser process, alongside the caller's own. */
30
27
  var BROWSER_LAUNCH_ARGS = Object.freeze(["--no-first-run", "--no-default-browser-check"]);
31
28
  /** Flag enabling headless mode on a launched browser process. */
32
29
  var BROWSER_HEADLESS_ARG = "--headless=new";
30
+ /** Prefix for isolated browser profiles created beneath the operating-system temp directory. */
31
+ var BROWSER_PROFILE_PREFIX = "orkestrel-browser-";
33
32
  /** Grace period after SIGTERM before a launched process is escalated to SIGKILL during teardown. */
34
33
  var BROWSER_KILL_GRACE_MS = 3e3;
35
34
  /** Bound for the `discover: false` port-occupancy probe before launching — short, since it only needs to detect an already-listening CDP endpoint, not perform full discovery. */
36
35
  var BROWSER_PORT_PROBE_TIMEOUT_MS = 200;
37
36
  /** 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
37
  var BROWSER_TRANSPORT_LOSS_DEFER_MS = 50;
38
+ /** Machine-readable error-context cause for an owned browser process exiting. */
39
+ var BROWSER_PROCESS_EXIT_CAUSE = "process-exit";
40
+ /** Machine-readable error-context cause for a CDP transport disconnecting while its browser remains alive. */
41
+ var BROWSER_TRANSPORT_LOSS_CAUSE = "transport-loss";
39
42
  /** Environment variables checked (in order) for an explicit browser executable path override. */
40
43
  var BROWSER_ENV_PATH_KEYS = Object.freeze(["PLAYWRIGHT_EXECUTABLE_PATH", "CHROME_PATH"]);
41
44
  /** Well-known Chrome/Chromium/Edge executable paths with no platform-specific root, keyed by `process.platform`. */
@@ -157,7 +160,7 @@ var BrowserDestroyedError = class extends BrowserError {
157
160
  * @returns True when value is a BrowserConnectionError instance
158
161
  */
159
162
  function isBrowserConnectionError(value) {
160
- return value instanceof BrowserConnectionError;
163
+ return isInstance(value, BrowserConnectionError);
161
164
  }
162
165
  /**
163
166
  * Narrow an unknown value to BrowserNotConnectedError.
@@ -166,7 +169,7 @@ function isBrowserConnectionError(value) {
166
169
  * @returns True when value is a BrowserNotConnectedError instance
167
170
  */
168
171
  function isBrowserNotConnectedError(value) {
169
- return value instanceof BrowserNotConnectedError;
172
+ return isInstance(value, BrowserNotConnectedError);
170
173
  }
171
174
  /**
172
175
  * Narrow an unknown value to BrowserDestroyedError.
@@ -175,7 +178,7 @@ function isBrowserNotConnectedError(value) {
175
178
  * @returns True when value is a BrowserDestroyedError instance
176
179
  */
177
180
  function isBrowserDestroyedError(value) {
178
- return value instanceof BrowserDestroyedError;
181
+ return isInstance(value, BrowserDestroyedError);
179
182
  }
180
183
  //#endregion
181
184
  //#region src/server/helpers.ts
@@ -259,10 +262,48 @@ function normalizeExecutablePath(path, platform) {
259
262
  }
260
263
  /** Classify a `/json/version` `Browser` string into a {@link BrowserEngine} (`Edg/` → edge, `Chrome/` → chrome, else chromium). */
261
264
  function browserToEngine(browser) {
262
- if (browser.includes("Edg/")) return "edge";
263
- if (browser.includes("Chrome/")) return "chrome";
265
+ if (browser?.includes("Edg/") === true) return "edge";
266
+ if (browser?.includes("Chrome/") === true) return "chrome";
264
267
  return "chromium";
265
268
  }
269
+ /**
270
+ * Resolve a persistent caller profile or create an isolated temporary one.
271
+ *
272
+ * @param path - Optional caller-owned persistent user-data directory
273
+ * @returns The resolved profile and whether the library owns its lifecycle
274
+ */
275
+ async function createBrowserProfile(path) {
276
+ if (path !== void 0) return {
277
+ path,
278
+ temporary: false
279
+ };
280
+ return {
281
+ path: await mkdtemp(join(tmpdir(), BROWSER_PROFILE_PREFIX)),
282
+ temporary: true
283
+ };
284
+ }
285
+ /**
286
+ * Remove a library-owned isolated browser profile.
287
+ *
288
+ * @remarks
289
+ * The path must remain a direct child of the operating-system temp directory
290
+ * with {@link BROWSER_PROFILE_PREFIX}; this defensive check prevents a
291
+ * malformed profile value from widening recursive deletion.
292
+ *
293
+ * @param profile - Resolved profile lifecycle result
294
+ */
295
+ async function removeBrowserProfile(profile) {
296
+ if (!profile.temporary) return;
297
+ const path = resolve(profile.path);
298
+ const temporary = resolve(tmpdir());
299
+ if (dirname(path) !== temporary || !basename(path).startsWith("orkestrel-browser-")) throw new Error("Refusing to remove an unsafe browser profile path");
300
+ await rm(path, {
301
+ recursive: true,
302
+ force: true,
303
+ maxRetries: 3,
304
+ retryDelay: 100
305
+ });
306
+ }
266
307
  /** Check the env-override keys (`PLAYWRIGHT_EXECUTABLE_PATH`, `CHROME_PATH`) in order for an existing file. */
267
308
  function findEnvOverride(env) {
268
309
  return findAllEnvOverrides(env)[0];
@@ -346,7 +387,7 @@ function findAllInStore(base, platform) {
346
387
  const glob = BROWSER_STORE_GLOBS[platform];
347
388
  if (glob === void 0) return found;
348
389
  const matches = globSync(`${base.replaceAll("\\", "/")}/${glob}`).filter((match) => existsSync(match));
349
- matches.sort((a, b) => a < b ? 1 : a > b ? -1 : 0);
390
+ matches.sort((a, b) => b.localeCompare(a, void 0, { numeric: true }));
350
391
  found.push(...matches);
351
392
  return found;
352
393
  }
@@ -378,23 +419,24 @@ function launchBrowserProcess(executable, port, headless, profile, extra) {
378
419
  *
379
420
  * @throws When the endpoint does not become ready before the timeout
380
421
  */
381
- async function waitForCdpReady(port, timeout, host = BROWSER_DEFAULT_HOST) {
422
+ async function waitForCDPReady(port, timeout, host = BROWSER_DEFAULT_HOST, signal) {
382
423
  const url = `${BROWSER_CDP_PROTOCOL}://${host}:${port}${BROWSER_CDP_VERSION_PATH}`;
383
424
  const deadline = Date.now() + timeout;
384
425
  while (Date.now() < deadline) {
426
+ signal?.throwIfAborted();
385
427
  const remaining = Math.max(0, deadline - Date.now());
386
- const controller = new AbortController();
387
- const timer = setTimeout(() => controller.abort(), remaining);
428
+ const requestSignal = signal === void 0 ? AbortSignal.timeout(remaining) : AbortSignal.any([signal, AbortSignal.timeout(remaining)]);
388
429
  try {
389
- const response = await fetch(url, { signal: controller.signal });
430
+ const response = await fetch(url, { signal: requestSignal });
390
431
  if (response.ok) {
391
432
  const info = await response.json();
392
433
  if (isRecord(info) && isString(info["webSocketDebuggerUrl"])) return info["webSocketDebuggerUrl"];
393
434
  }
394
- } catch {} finally {
395
- clearTimeout(timer);
435
+ } catch (error) {
436
+ if (signal?.aborted === true) throw error;
396
437
  }
397
- await new Promise((resolve) => setTimeout(resolve, BROWSER_WAIT_POLL_INTERVAL_MS));
438
+ const delay = Math.min(BROWSER_WAIT_POLL_INTERVAL_MS, Math.max(0, deadline - Date.now()));
439
+ if (delay > 0) await setTimeout$1(delay, void 0, { signal });
398
440
  }
399
441
  throw new Error(`CDP endpoint on port ${port} did not become ready within ${timeout}ms`);
400
442
  }
@@ -406,7 +448,7 @@ async function waitForCdpReady(port, timeout, host = BROWSER_DEFAULT_HOST) {
406
448
  * @param host - Host the browser exposes its CDP endpoint on (default `127.0.0.1`)
407
449
  * @returns Normalized CDP targets
408
450
  */
409
- async function fetchCdpTargets(port, timeout, host = BROWSER_DEFAULT_HOST) {
451
+ async function fetchCDPTargets(port, timeout, host = BROWSER_DEFAULT_HOST) {
410
452
  const url = `${BROWSER_CDP_PROTOCOL}://${host}:${port}${BROWSER_CDP_LIST_PATH}`;
411
453
  const controller = new AbortController();
412
454
  const timer = setTimeout(() => controller.abort(), timeout);
@@ -414,16 +456,16 @@ async function fetchCdpTargets(port, timeout, host = BROWSER_DEFAULT_HOST) {
414
456
  const response = await fetch(url, { signal: controller.signal });
415
457
  if (!response.ok) return [];
416
458
  const list = await response.json();
417
- if (!Array.isArray(list)) return [];
459
+ if (!isArray(list)) return [];
418
460
  const targets = [];
419
461
  for (const entry of list) {
420
462
  if (!isRecord(entry)) continue;
421
- if (!isString(entry["id"]) || !isString(entry["type"])) continue;
463
+ if (!isString(entry["id"]) || !isString(entry["type"]) || !isString(entry["title"]) || !isString(entry["url"])) continue;
422
464
  targets.push({
423
465
  id: entry["id"],
424
466
  type: entry["type"],
425
- title: isString(entry["title"]) ? entry["title"] : "",
426
- url: isString(entry["url"]) ? entry["url"] : ""
467
+ title: entry["title"],
468
+ url: entry["url"]
427
469
  });
428
470
  }
429
471
  return targets;
@@ -436,33 +478,32 @@ async function fetchCdpTargets(port, timeout, host = BROWSER_DEFAULT_HOST) {
436
478
  //#endregion
437
479
  //#region src/server/transports/WebSocketCDPTransport.ts
438
480
  /**
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.
481
+ * `@orkestrel/websocket`-backed raw CDP text transport.
442
482
  *
443
483
  * @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`.
484
+ * Performs and validates the RFC 6455 HTTP upgrade, then delegates frame
485
+ * handling to `@orkestrel/websocket`. Concurrent and repeated `start()` /
486
+ * `close()` calls share their active transition, and a later `start()` opens a
487
+ * fresh socket after the prior one closes.
457
488
  */
458
489
  var WebSocketCDPTransport = class {
459
490
  #emitter;
460
491
  #url;
461
492
  #timeout;
462
493
  #socket;
463
- #connecting;
494
+ #starting;
495
+ #closing;
496
+ #request;
497
+ #resolve;
498
+ #reject;
499
+ #timer;
500
+ #closeResolve;
501
+ #onSocketClose = () => this.#closeResolve?.();
464
502
  constructor(options) {
465
- this.#emitter = new Emitter();
503
+ this.#emitter = new Emitter({
504
+ on: options.on,
505
+ error: options.error
506
+ });
466
507
  this.#url = options.url;
467
508
  this.#timeout = options.timeout ?? BROWSER_DEFAULT_TIMEOUT_MS;
468
509
  }
@@ -470,62 +511,22 @@ var WebSocketCDPTransport = class {
470
511
  return this.#emitter;
471
512
  }
472
513
  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);
514
+ if (this.#closing !== void 0) await this.#closing;
515
+ if (this.#socket?.readyState === WEBSOCKET_READY_OPEN) return;
516
+ const active = this.#starting;
517
+ if (active !== void 0) {
518
+ await active;
519
+ return;
520
+ }
521
+ this.#socket?.destroy();
522
+ this.#socket = void 0;
523
+ const transition = this.#open();
524
+ this.#starting = transition;
525
+ try {
526
+ await transition;
527
+ } finally {
528
+ if (this.#starting === transition) this.#starting = void 0;
529
+ }
529
530
  }
530
531
  async send(data) {
531
532
  const socket = this.#socket;
@@ -533,32 +534,144 @@ var WebSocketCDPTransport = class {
533
534
  socket.send(data);
534
535
  }
535
536
  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 }));
537
+ const active = this.#closing;
538
+ if (active !== void 0) {
539
+ await active;
542
540
  return;
543
541
  }
542
+ const transition = this.#stop();
543
+ this.#closing = transition;
544
+ try {
545
+ await transition;
546
+ } finally {
547
+ if (this.#closing === transition) this.#closing = void 0;
548
+ }
549
+ }
550
+ async #open() {
551
+ const parsed = attempt(() => new URL(this.#url));
552
+ if (!parsed.success) throw new BrowserConnectionError(`WebSocket CDP URL is invalid: ${this.#url}`, {
553
+ url: this.#url,
554
+ error: parsed.error
555
+ });
556
+ const url = parsed.value;
557
+ if (url.protocol !== "ws:" && url.protocol !== "wss:") throw new BrowserConnectionError(`WebSocket CDP connection requires a ws: or wss: URL: ${this.#url}`, { url: this.#url });
558
+ const key = randomBytes(16).toString("base64");
559
+ const deferred = Promise.withResolvers();
560
+ const options = {
561
+ hostname: url.hostname,
562
+ port: url.port.length > 0 ? Number(url.port) : url.protocol === "wss:" ? 443 : 80,
563
+ path: `${url.pathname}${url.search}`,
564
+ headers: {
565
+ Connection: "Upgrade",
566
+ Upgrade: "websocket",
567
+ "Sec-WebSocket-Key": key,
568
+ "Sec-WebSocket-Version": WEBSOCKET_VERSION
569
+ }
570
+ };
571
+ const request$2 = url.protocol === "wss:" ? request$1(options) : request(options);
572
+ this.#request = request$2;
573
+ this.#resolve = deferred.resolve;
574
+ this.#reject = deferred.reject;
575
+ this.#timer = setTimeout(() => this.#expire(request$2), this.#timeout);
576
+ request$2.on("upgrade", (response, socket, head) => {
577
+ this.#upgrade(request$2, key, response, socket, head);
578
+ });
579
+ request$2.on("response", (response) => this.#decline(request$2, response));
580
+ request$2.on("error", (error) => this.#fail(request$2, error));
581
+ request$2.end();
582
+ const socket = await deferred.promise;
583
+ this.#socket = socket;
584
+ this.#bind(socket);
585
+ }
586
+ async #stop() {
587
+ const request = this.#request;
588
+ if (request !== void 0) {
589
+ this.#rejectRequest(request, new BrowserConnectionError(`WebSocket CDP connection to ${this.#url} was closed before it finished connecting`, { url: this.#url }));
590
+ request.destroy();
591
+ }
592
+ await this.#starting?.catch(() => void 0);
544
593
  const socket = this.#socket;
545
- if (socket === void 0) return;
546
594
  this.#socket = void 0;
547
- await new Promise((resolve) => {
548
- socket.emitter.on("close", () => resolve());
595
+ if (socket === void 0 || socket.readyState === WEBSOCKET_READY_CLOSED) return;
596
+ const closed = Promise.withResolvers();
597
+ this.#closeResolve = closed.resolve;
598
+ socket.emitter.on("close", this.#onSocketClose);
599
+ try {
549
600
  socket.close();
550
- });
601
+ if (socket.readyState !== WEBSOCKET_READY_CLOSED) await closed.promise;
602
+ } catch {
603
+ socket.destroy();
604
+ if (socket.readyState !== WEBSOCKET_READY_CLOSED) await closed.promise;
605
+ } finally {
606
+ socket.emitter.off("close", this.#onSocketClose);
607
+ this.#closeResolve = void 0;
608
+ }
551
609
  }
552
- #bind(ws) {
553
- ws.emitter.on("message", (data) => {
554
- this.#emitter.emit("message", data);
555
- });
556
- ws.emitter.on("close", () => {
610
+ #upgrade(request, key, response, socket, head) {
611
+ if (this.#request !== request) {
612
+ socket.destroy();
613
+ return;
614
+ }
615
+ const accept = response.headers["sec-websocket-accept"];
616
+ if (!isString(accept) || accept !== computeWebSocketAccept(key)) {
617
+ socket.destroy();
618
+ this.#rejectRequest(request, new BrowserConnectionError(`WebSocket CDP connection to ${this.#url} failed: Sec-WebSocket-Accept mismatch`, { url: this.#url }));
619
+ return;
620
+ }
621
+ try {
622
+ this.#resolveRequest(request, createNodeWebSocket({
623
+ socket,
624
+ head
625
+ }));
626
+ } catch (error) {
627
+ socket.destroy();
628
+ this.#rejectRequest(request, error);
629
+ }
630
+ }
631
+ #decline(request, response) {
632
+ response.resume();
633
+ const reason = response.statusCode === void 0 ? "upgrade was declined without a status code" : `upgrade declined with status ${response.statusCode}`;
634
+ this.#rejectRequest(request, new BrowserConnectionError(`WebSocket CDP connection to ${this.#url} failed: ${reason}`, { url: this.#url }));
635
+ }
636
+ #fail(request, error) {
637
+ this.#rejectRequest(request, new BrowserConnectionError(`WebSocket CDP connection to ${this.#url} failed: ${error.message}`, { url: this.#url }));
638
+ }
639
+ #expire(request) {
640
+ this.#rejectRequest(request, new BrowserConnectionError(`WebSocket CDP connection to ${this.#url} timed out after ${this.#timeout}ms`, {
641
+ url: this.#url,
642
+ timeout: this.#timeout
643
+ }));
644
+ request.destroy();
645
+ }
646
+ #resolveRequest(request, socket) {
647
+ if (this.#request !== request) {
648
+ socket.destroy();
649
+ return;
650
+ }
651
+ const resolve = this.#resolve;
652
+ this.#clearRequest();
653
+ resolve?.(socket);
654
+ }
655
+ #rejectRequest(request, error) {
656
+ if (this.#request !== request) return;
657
+ const reject = this.#reject;
658
+ this.#clearRequest();
659
+ reject?.(error);
660
+ }
661
+ #clearRequest() {
662
+ if (this.#timer !== void 0) clearTimeout(this.#timer);
663
+ this.#timer = void 0;
664
+ this.#request = void 0;
665
+ this.#resolve = void 0;
666
+ this.#reject = void 0;
667
+ }
668
+ #bind(socket) {
669
+ socket.emitter.on("message", (data) => this.#emitter.emit("message", data));
670
+ socket.emitter.on("close", () => {
671
+ if (this.#socket === socket) this.#socket = void 0;
557
672
  this.#emitter.emit("close");
558
673
  });
559
- ws.emitter.on("error", (error) => {
560
- this.#emitter.emit("error", error);
561
- });
674
+ socket.emitter.on("error", (error) => this.#emitter.emit("error", error));
562
675
  }
563
676
  };
564
677
  //#endregion
@@ -602,23 +715,37 @@ function createScreenshotWriter() {
602
715
  }
603
716
  //#endregion
604
717
  //#region src/server/Browser.ts
718
+ /**
719
+ * Discovers, launches, connects to, and owns Chromium-family browser sessions.
720
+ */
605
721
  var Browser = class {
606
722
  #emitter;
607
723
  #options;
724
+ #abort = new AbortController();
725
+ #cdpPort;
726
+ #cdpHost;
608
727
  #engine;
609
728
  #status = "idle";
610
729
  #connection;
730
+ #owned;
731
+ #endpoint;
611
732
  #client;
612
733
  #process;
613
- #cdpPort;
614
- #cdpHost;
734
+ #profile;
615
735
  #contexts = [];
616
736
  #destroyed = false;
617
- #transportUnbind;
618
- #processUnbind;
619
- #lastPid;
737
+ #connecting;
738
+ #disconnecting;
739
+ #shutdown;
740
+ #transport;
741
+ #onTransportClose = () => this.#handleTransportLoss();
742
+ #onTransportError = () => this.#handleTransportLoss();
743
+ #onProcessExit = () => this.#handleProcessExit();
620
744
  constructor(options) {
621
- this.#emitter = new Emitter({ on: options?.on });
745
+ this.#emitter = new Emitter({
746
+ on: options?.on,
747
+ error: options?.error
748
+ });
622
749
  this.#options = options ?? {};
623
750
  this.#engine = this.#options.engine ?? (this.#options.executable !== void 0 ? parseBrowserEngine(this.#options.executable) ?? "chromium" : "chromium");
624
751
  this.#cdpPort = this.#options.cdp?.port ?? 9222;
@@ -637,63 +764,62 @@ var Browser = class {
637
764
  get connection() {
638
765
  return this.#connection;
639
766
  }
767
+ get owned() {
768
+ return this.#owned;
769
+ }
640
770
  get connected() {
641
771
  return this.#status === "connected";
642
772
  }
643
773
  get pid() {
644
- return this.#process?.pid ?? this.#lastPid;
774
+ return this.#process?.pid;
645
775
  }
646
776
  async discover() {
647
- const result = await this.#discoverCdp();
777
+ const result = await this.#discoverCDP();
648
778
  this.#emitter.emit("discover", result);
649
779
  return result;
650
780
  }
651
781
  async connect() {
782
+ if (this.#destroyed) throw new BrowserDestroyedError();
783
+ if (this.#disconnecting !== void 0) await this.#disconnecting;
652
784
  if (this.#destroyed) throw new BrowserDestroyedError();
653
785
  if (this.#status === "connected") return;
654
- this.#assertNotAborted();
655
- this.#status = "connecting";
786
+ const active = this.#connecting;
787
+ if (active !== void 0) {
788
+ await active;
789
+ return;
790
+ }
791
+ const attempt = this.#establish();
792
+ this.#connecting = attempt;
656
793
  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 });
794
+ await attempt;
795
+ } finally {
796
+ if (this.#connecting === attempt) this.#connecting = void 0;
681
797
  }
682
798
  }
799
+ adopt() {
800
+ if (this.#destroyed) throw new BrowserDestroyedError();
801
+ if (this.#status !== "connected" || this.#client === void 0 || this.#endpoint === void 0) throw new BrowserNotConnectedError();
802
+ this.#owned = true;
803
+ }
683
804
  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");
805
+ if (this.#destroyed) return;
806
+ const active = this.#disconnecting;
807
+ if (active !== void 0) {
808
+ await active;
809
+ return;
810
+ }
811
+ if (this.#connecting !== void 0) {
812
+ await this.#connecting.catch(() => void 0);
813
+ if (this.#destroyed) return;
814
+ }
815
+ if (this.#status !== "connected" || this.#client === void 0) return;
816
+ const attempt = this.#detach();
817
+ this.#disconnecting = attempt;
818
+ try {
819
+ await attempt;
820
+ } finally {
821
+ if (this.#disconnecting === attempt) this.#disconnecting = void 0;
822
+ }
697
823
  }
698
824
  context(index) {
699
825
  const i = index ?? 0;
@@ -703,239 +829,250 @@ var Browser = class {
703
829
  contexts() {
704
830
  return [...this.#contexts];
705
831
  }
832
+ async isolate(options) {
833
+ if (this.#destroyed) throw new BrowserDestroyedError();
834
+ const client = this.#client;
835
+ if (this.#status !== "connected" || client === void 0) throw new BrowserNotConnectedError();
836
+ validateBrowserContextOptions(options);
837
+ const params = { disposeOnDetach: false };
838
+ if (options?.proxy !== void 0) {
839
+ params["proxyServer"] = options.proxy.server;
840
+ if (options.proxy.bypass !== void 0) params["proxyBypassList"] = options.proxy.bypass.join(",");
841
+ }
842
+ if (options?.origins !== void 0) params["originsWithUniversalNetworkAccess"] = [...options.origins];
843
+ const result = await client.send("Target.createBrowserContext", params);
844
+ if (!isRecord(result) || !isString(result["browserContextId"])) throw new BrowserConnectionError("Failed to create isolated browser context");
845
+ const id = result["browserContextId"];
846
+ const context = new BrowserContext(client, id, options?.emulation?.viewport ?? this.#options.viewport, createScreenshotWriter(), options?.emulation, options?.downloads);
847
+ try {
848
+ if (options?.downloads !== void 0) await client.send("Browser.setDownloadBehavior", {
849
+ behavior: options.downloads.named === true ? "allowAndName" : "allow",
850
+ browserContextId: id,
851
+ downloadPath: options.downloads.path,
852
+ eventsEnabled: true
853
+ });
854
+ } catch (error) {
855
+ await context.close().catch(() => void 0);
856
+ throw error;
857
+ }
858
+ this.#registerContext(context);
859
+ return context;
860
+ }
706
861
  async create(options) {
707
862
  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);
863
+ const client = this.#client;
864
+ if (this.#status !== "connected" || client === void 0) throw new BrowserNotConnectedError();
865
+ let context = this.#contexts[0];
866
+ if (context === void 0) {
867
+ context = new BrowserContext(client, void 0, this.#options.viewport, createScreenshotWriter());
868
+ this.#registerContext(context);
713
869
  }
714
- const page = await ctx.create(options);
870
+ const page = await context.create(options);
715
871
  this.#emitter.emit("page", page);
716
872
  return page;
717
873
  }
718
- async destroy() {
719
- if (this.#destroyed) return;
874
+ destroy() {
875
+ const active = this.#shutdown;
876
+ if (active !== void 0) return active;
877
+ if (this.#destroyed) return Promise.resolve();
720
878
  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
- }
879
+ this.#abort.abort();
880
+ const shutdown = this.#destroyResources();
881
+ this.#shutdown = shutdown;
882
+ return shutdown;
743
883
  }
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;
884
+ close() {
885
+ const active = this.#shutdown;
886
+ if (active !== void 0) return active;
887
+ if (this.#destroyed) return Promise.resolve();
756
888
  this.#destroyed = true;
889
+ this.#abort.abort();
890
+ const shutdown = this.#closeResources();
891
+ this.#shutdown = shutdown;
892
+ return shutdown;
893
+ }
894
+ async #establish() {
895
+ this.#assertNotAborted();
896
+ this.#status = "connecting";
757
897
  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;
898
+ if (this.#owned === true && this.#endpoint !== void 0) {
899
+ await this.#connectCDP(this.#endpoint);
900
+ return;
776
901
  }
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;
902
+ const endpoint = this.#options.cdp?.endpoint;
903
+ if (endpoint !== void 0) {
904
+ await this.#connectCDP(endpoint);
905
+ return;
786
906
  }
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();
907
+ if (this.#options.cdp?.discover ?? true) {
908
+ const discovery = await this.#raceAbort(this.#discoverCDP(this.#signal()));
909
+ if (discovery.found && discovery.endpoint !== void 0) {
910
+ this.#engine = browserToEngine(discovery.browser);
911
+ await this.#connectCDP(discovery.endpoint);
912
+ return;
913
+ }
914
+ } else await this.#raceAbort(this.#assertPortFree());
915
+ this.#assertNotAborted();
916
+ await this.#launch();
917
+ } catch (error) {
918
+ if (this.#destroyed) throw new BrowserDestroyedError();
919
+ this.#status = "error";
920
+ this.#emitter.emit("error", error);
921
+ if (isBrowserConnectionError(error)) throw error;
922
+ throw new BrowserConnectionError(isError(error) ? error.message : String(error), { executable: this.#options.executable });
793
923
  }
794
924
  }
925
+ async #detach() {
926
+ this.#status = "disconnected";
927
+ this.#connection = void 0;
928
+ this.#unbindTransport();
929
+ const client = this.#client;
930
+ this.#client = void 0;
931
+ await this.#destroyContexts();
932
+ this.#contexts = [];
933
+ if (this.#owned !== true) {
934
+ this.#owned = void 0;
935
+ this.#endpoint = void 0;
936
+ }
937
+ await this.#closeClient(client);
938
+ if (this.#destroyed) return;
939
+ this.#emitter.emit("disconnect");
940
+ this.#emitter.emit("idle");
941
+ }
795
942
  #assertNotAborted() {
796
- if (this.#options.signal?.aborted) throw new BrowserConnectionError("Connection aborted");
943
+ if (this.#signal().aborted) throw new BrowserConnectionError("Connection aborted");
944
+ }
945
+ #signal() {
946
+ const external = this.#options.signal;
947
+ return external === void 0 ? this.#abort.signal : AbortSignal.any([this.#abort.signal, external]);
797
948
  }
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
949
  async #raceAbort(promise) {
803
- const signal = this.#options.signal;
804
- if (signal === void 0) return promise;
950
+ const signal = this.#signal();
805
951
  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;
952
+ const aborted = Promise.withResolvers();
953
+ const listener = addAbortListener(signal, () => {
954
+ aborted.reject(new BrowserConnectionError("Connection aborted"));
825
955
  });
956
+ try {
957
+ return await Promise.race([promise, aborted.promise]);
958
+ } finally {
959
+ listener[Symbol.dispose]();
960
+ promise.catch(() => void 0);
961
+ }
826
962
  }
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
963
  #handleProcessExit() {
840
964
  if (this.#destroyed) return;
841
- if (this.#status !== "connected") return;
965
+ const connected = this.#status === "connected";
966
+ const client = this.#client;
967
+ const contexts = this.#contexts;
842
968
  this.#unbindTransport();
843
969
  this.#unbindProcess();
844
- const client = this.#client;
845
970
  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");
971
+ this.#releaseProfile().catch((error) => this.#emitter.emit("error", error));
972
+ this.#client = void 0;
973
+ this.#contexts = [];
974
+ this.#connection = void 0;
975
+ this.#owned = void 0;
976
+ this.#endpoint = void 0;
977
+ if (connected) this.#status = "disconnected";
978
+ this.#destroyContextList(contexts);
979
+ this.#closeClient(client);
980
+ this.#emitter.emit("error", new BrowserConnectionError("The browser process exited unexpectedly", { cause: BROWSER_PROCESS_EXIT_CAUSE }));
981
+ if (connected) {
982
+ this.#emitter.emit("disconnect");
983
+ this.#emitter.emit("idle");
984
+ }
852
985
  }
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;
986
+ #handleTransportLoss() {
987
+ if (this.#destroyed || this.#status !== "connected") return;
863
988
  const process = this.#process;
864
989
  if (process !== void 0 && (process.exitCode !== null || process.signalCode !== null)) {
865
990
  this.#handleProcessExit();
866
991
  return;
867
992
  }
868
- if (process !== void 0 && !deferred) {
869
- setTimeout(() => this.#handleTransportLoss(true), 50);
993
+ if (process !== void 0) {
994
+ setTimeout(() => this.#confirmTransportLoss(), 50);
870
995
  return;
871
996
  }
872
- if (process !== void 0 && process.pid !== void 0) try {
997
+ this.#confirmTransportLoss();
998
+ }
999
+ #confirmTransportLoss() {
1000
+ if (this.#destroyed || this.#status !== "connected") return;
1001
+ const process = this.#process;
1002
+ if (process !== void 0 && (process.exitCode !== null || process.signalCode !== null)) {
1003
+ this.#handleProcessExit();
1004
+ return;
1005
+ }
1006
+ if (process?.pid !== void 0) try {
873
1007
  process.kill(0);
874
1008
  } catch {
875
1009
  this.#handleProcessExit();
876
1010
  return;
877
1011
  }
878
- this.#unbindTransport();
879
1012
  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" }));
1013
+ const owned = this.#owned === true;
1014
+ const contexts = this.#contexts;
1015
+ this.#unbindTransport();
1016
+ this.#client = void 0;
1017
+ this.#contexts = [];
1018
+ this.#connection = void 0;
1019
+ this.#status = "disconnected";
1020
+ if (!owned) {
1021
+ this.#owned = void 0;
1022
+ this.#endpoint = void 0;
1023
+ }
1024
+ this.#destroyContextList(contexts);
1025
+ this.#closeClient(client);
1026
+ this.#emitter.emit("error", new BrowserConnectionError("The CDP transport connection was lost", { cause: BROWSER_TRANSPORT_LOSS_CAUSE }));
883
1027
  this.#emitter.emit("disconnect");
884
1028
  this.#emitter.emit("idle");
885
1029
  }
886
- /** Subscribe to the CDP transport's close/error events for deterministic transport-loss detection. */
887
1030
  #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
- };
1031
+ this.#unbindTransport();
1032
+ this.#transport = transport;
1033
+ transport.emitter.on("close", this.#onTransportClose);
1034
+ transport.emitter.on("error", this.#onTransportError);
896
1035
  }
897
1036
  #unbindTransport() {
898
- this.#transportUnbind?.();
899
- this.#transportUnbind = void 0;
1037
+ const transport = this.#transport;
1038
+ if (transport === void 0) return;
1039
+ transport.emitter.off("close", this.#onTransportClose);
1040
+ transport.emitter.off("error", this.#onTransportError);
1041
+ this.#transport = void 0;
900
1042
  }
901
- /** Subscribe to a launched process's exit event for deterministic process-exit detection. */
902
1043
  #bindProcess(process) {
903
- const onExit = () => this.#handleProcessExit();
904
- process.once("exit", onExit);
905
- this.#processUnbind = () => process.off("exit", onExit);
1044
+ this.#unbindProcess();
1045
+ process.once("exit", this.#onProcessExit);
1046
+ if (process.exitCode !== null || process.signalCode !== null) queueMicrotask(this.#onProcessExit);
906
1047
  }
907
1048
  #unbindProcess() {
908
- this.#processUnbind?.();
909
- this.#processUnbind = void 0;
1049
+ this.#process?.off("exit", this.#onProcessExit);
910
1050
  }
911
1051
  #timeout() {
912
1052
  return this.#options.timeout ?? BROWSER_DEFAULT_TIMEOUT_MS;
913
1053
  }
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());
1054
+ async #discoverCDP(signal) {
1055
+ const url = `${BROWSER_CDP_PROTOCOL}://${this.#cdpHost}:${this.#cdpPort}${BROWSER_CDP_VERSION_PATH}`;
1056
+ const requestSignal = signal === void 0 ? AbortSignal.timeout(this.#timeout()) : AbortSignal.any([signal, AbortSignal.timeout(this.#timeout())]);
919
1057
  try {
920
- const response = await fetch(url, { signal: controller.signal });
921
- if (!response.ok) return this.#notFoundResult();
1058
+ const response = await fetch(url, { signal: requestSignal });
1059
+ if (!response.ok) return this.#notFound();
922
1060
  const info = await response.json();
923
- if (!isRecord(info)) return this.#notFoundResult();
1061
+ if (!isRecord(info)) return this.#notFound();
924
1062
  const endpoint = isString(info["webSocketDebuggerUrl"]) ? info["webSocketDebuggerUrl"] : void 0;
925
- const browserName = isString(info["Browser"]) ? info["Browser"] : void 0;
1063
+ const browser = isString(info["Browser"]) ? info["Browser"] : void 0;
926
1064
  return {
927
1065
  found: endpoint !== void 0,
928
1066
  endpoint,
929
- browser: browserName,
1067
+ browser,
930
1068
  connection: endpoint !== void 0 ? "cdp" : void 0
931
1069
  };
932
- } catch {
933
- return this.#notFoundResult();
934
- } finally {
935
- clearTimeout(timer);
1070
+ } catch (error) {
1071
+ if (signal?.aborted === true) throw error;
1072
+ return this.#notFound();
936
1073
  }
937
1074
  }
938
- #notFoundResult() {
1075
+ #notFound() {
939
1076
  return {
940
1077
  found: false,
941
1078
  endpoint: void 0,
@@ -943,31 +1080,21 @@ var Browser = class {
943
1080
  connection: void 0
944
1081
  };
945
1082
  }
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
1083
  async #assertPortFree() {
952
1084
  if (await this.#probePort()) throw new BrowserConnectionError(`Port ${this.#cdpPort} on ${this.#cdpHost} is already occupied by another CDP endpoint`, {
953
1085
  port: this.#cdpPort,
954
1086
  host: this.#cdpHost
955
1087
  });
956
1088
  }
957
- /** Very short probe of the CDP version endpoint — just enough to detect an occupied port, not full discovery. */
958
1089
  async #probePort() {
959
1090
  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
1091
  try {
963
- return (await fetch(url, { signal: controller.signal })).ok;
1092
+ return (await fetch(url, { signal: AbortSignal.timeout(200) })).ok;
964
1093
  } catch {
965
1094
  return false;
966
- } finally {
967
- clearTimeout(timer);
968
1095
  }
969
1096
  }
970
- async #connectCdp(endpoint) {
1097
+ async #connectCDP(endpoint) {
971
1098
  const transport = createCDPTransport({
972
1099
  url: endpoint,
973
1100
  timeout: this.#timeout()
@@ -976,18 +1103,27 @@ var Browser = class {
976
1103
  transport,
977
1104
  timeout: this.#timeout()
978
1105
  });
1106
+ const retained = this.#owned === true && this.#endpoint === endpoint;
979
1107
  try {
980
1108
  await this.#raceAbort(client.connect());
981
1109
  this.#client = client;
982
- this.#connection = "cdp";
1110
+ this.#endpoint = endpoint;
1111
+ this.#connection = retained ? this.#retainedConnection() : "cdp";
1112
+ this.#owned = retained;
983
1113
  this.#status = "connected";
984
1114
  this.#bindTransport(transport);
985
1115
  await this.#syncContexts();
986
- this.#emitter.emit("connect", "cdp");
1116
+ this.#emitter.emit("connect", this.#connection);
987
1117
  } catch (error) {
988
- try {
989
- await client.close();
990
- } catch {}
1118
+ this.#unbindTransport();
1119
+ if (this.#client === client) this.#client = void 0;
1120
+ this.#contexts = [];
1121
+ this.#connection = void 0;
1122
+ if (!retained) {
1123
+ this.#owned = void 0;
1124
+ this.#endpoint = void 0;
1125
+ }
1126
+ await this.#closeClient(client);
991
1127
  throw error;
992
1128
  }
993
1129
  }
@@ -1005,18 +1141,24 @@ var Browser = class {
1005
1141
  executable = found?.executable;
1006
1142
  resolvedEngine = found?.engine;
1007
1143
  }
1008
- if (executable === void 0) throw new BrowserConnectionError("No Chromium browser found. Install Chrome, Edge, or Chromium.", requestedEngine !== void 0 ? { engine: requestedEngine } : void 0);
1144
+ if (executable === void 0) throw new BrowserConnectionError("No Chromium browser found. Install Chrome, Edge, or Chromium.", requestedEngine === void 0 ? void 0 : { engine: requestedEngine });
1009
1145
  this.#engine = resolvedEngine ?? "chromium";
1010
- const headless = this.#options.headless ?? true;
1011
- const profile = this.#options.profile;
1012
- const extra = this.#options.args;
1013
- const process = launchBrowserProcess(executable, this.#cdpPort, headless, profile, extra);
1146
+ await this.#releaseProfile();
1147
+ const profile = await createBrowserProfile(this.#options.profile);
1148
+ this.#profile = profile;
1149
+ let process;
1150
+ try {
1151
+ process = launchBrowserProcess(executable, this.#cdpPort, this.#options.headless ?? true, profile.path, this.#options.args);
1152
+ } catch (error) {
1153
+ await this.#releaseProfile();
1154
+ throw error;
1155
+ }
1014
1156
  this.#process = process;
1015
- let transport;
1016
1157
  let client;
1017
1158
  try {
1018
- transport = createCDPTransport({
1019
- url: await this.#raceAbort(this.#waitForLaunch(process, executable, extra)),
1159
+ const endpoint = await this.#waitForLaunch(process, executable, this.#options.args);
1160
+ const transport = createCDPTransport({
1161
+ url: endpoint,
1020
1162
  timeout: this.#timeout()
1021
1163
  });
1022
1164
  client = new CDPClient({
@@ -1024,20 +1166,27 @@ var Browser = class {
1024
1166
  timeout: this.#timeout()
1025
1167
  });
1026
1168
  await this.#raceAbort(client.connect());
1169
+ const connection = this.#options.profile === void 0 ? "launch" : "persistent";
1027
1170
  this.#client = client;
1028
- this.#connection = profile !== void 0 ? "persistent" : "launch";
1171
+ this.#endpoint = endpoint;
1172
+ this.#connection = connection;
1173
+ this.#owned = true;
1029
1174
  this.#status = "connected";
1030
1175
  this.#bindTransport(transport);
1031
1176
  this.#bindProcess(process);
1032
1177
  await this.#syncContexts();
1033
1178
  this.#emitter.emit("launch", this.#engine);
1034
- this.#emitter.emit("connect", this.#connection);
1179
+ this.#emitter.emit("connect", connection);
1035
1180
  } catch (error) {
1036
- if (client !== void 0) try {
1037
- await client.close();
1038
- } catch {}
1181
+ this.#unbindTransport();
1182
+ this.#unbindProcess();
1183
+ await this.#closeClient(client);
1039
1184
  await this.#terminate(process);
1185
+ await this.#releaseProfile();
1040
1186
  this.#process = void 0;
1187
+ this.#client = void 0;
1188
+ this.#endpoint = void 0;
1189
+ this.#owned = void 0;
1041
1190
  throw error;
1042
1191
  }
1043
1192
  }
@@ -1046,75 +1195,212 @@ var Browser = class {
1046
1195
  executable,
1047
1196
  args
1048
1197
  };
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
- });
1198
+ const controller = new AbortController();
1199
+ const signal = AbortSignal.any([controller.signal, this.#signal()]);
1200
+ const ready = waitForCDPReady(this.#cdpPort, this.#timeout(), this.#cdpHost, signal);
1201
+ const exited = once(process, "exit", { signal }).then((values) => {
1202
+ const code = isInteger(values[0]) ? values[0] : null;
1203
+ const exitSignal = isString(values[1]) ? values[1] : null;
1204
+ throw new BrowserConnectionError(this.#formatLaunchExit(code, exitSignal), context);
1072
1205
  });
1206
+ try {
1207
+ return await Promise.race([ready, exited]);
1208
+ } catch (error) {
1209
+ if (isBrowserConnectionError(error)) throw error;
1210
+ if (this.#signal().aborted) throw new BrowserConnectionError("Connection aborted", context);
1211
+ throw new BrowserConnectionError(isError(error) ? error.message : String(error), context);
1212
+ } finally {
1213
+ controller.abort();
1214
+ ready.catch(() => void 0);
1215
+ exited.catch(() => void 0);
1216
+ }
1073
1217
  }
1074
1218
  #formatLaunchExit(code, signal) {
1075
1219
  if (signal !== null) return `Browser process exited before CDP became ready (signal: ${signal})`;
1076
1220
  if (code !== null) return `Browser process exited before CDP became ready (code: ${code})`;
1077
1221
  return "Browser process exited before CDP became ready";
1078
1222
  }
1223
+ #retainedConnection() {
1224
+ if (this.#process === void 0) return "cdp";
1225
+ return this.#options.profile === void 0 ? "launch" : "persistent";
1226
+ }
1079
1227
  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);
1228
+ const client = this.#client;
1229
+ if (client === void 0) return;
1230
+ let result;
1231
+ try {
1232
+ result = await client.send("Target.getTargets");
1233
+ } catch {
1234
+ return;
1086
1235
  }
1236
+ if (!isRecord(result) || !isArray(result["targetInfos"])) return;
1237
+ const pages = [];
1238
+ for (const target of result["targetInfos"]) {
1239
+ if (!isRecord(target) || !isString(target["targetId"]) || target["type"] !== "page" || !isString(target["title"]) || !isString(target["url"])) continue;
1240
+ pages.push({
1241
+ id: target["targetId"],
1242
+ type: target["type"],
1243
+ title: target["title"],
1244
+ url: target["url"]
1245
+ });
1246
+ }
1247
+ if (pages.length === 0 || this.#contexts.length > 0) return;
1248
+ const context = new BrowserContext(client, void 0, this.#options.viewport, createScreenshotWriter());
1249
+ await context.sync(pages);
1250
+ this.#registerContext(context);
1087
1251
  }
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;
1095
- if (process.exitCode !== null || process.signalCode !== null) return;
1096
- const exited = new Promise((resolve) => {
1097
- process.once("exit", () => resolve());
1252
+ async #destroyResources() {
1253
+ try {
1254
+ await this.#settle();
1255
+ this.#unbindTransport();
1256
+ this.#unbindProcess();
1257
+ const client = this.#client;
1258
+ if (this.#owned === true) {
1259
+ await this.#closeContexts();
1260
+ if (this.#process === void 0) await this.#closeRemote(client);
1261
+ } else await this.#destroyContexts();
1262
+ this.#contexts = [];
1263
+ await this.#terminate(this.#process);
1264
+ this.#process = void 0;
1265
+ await this.#closeClient(client);
1266
+ this.#client = void 0;
1267
+ } finally {
1268
+ try {
1269
+ await this.#releaseProfile();
1270
+ } finally {
1271
+ this.#finish();
1272
+ }
1273
+ }
1274
+ }
1275
+ async #closeResources() {
1276
+ try {
1277
+ await this.#settle();
1278
+ this.#unbindTransport();
1279
+ this.#unbindProcess();
1280
+ const client = this.#client;
1281
+ await this.#closeContexts();
1282
+ this.#contexts = [];
1283
+ await this.#closeRemote(client);
1284
+ const process = this.#process;
1285
+ if (process !== void 0) {
1286
+ if (!await this.#waitForProcessWithin(process, 3e3)) await this.#terminate(process);
1287
+ }
1288
+ this.#process = void 0;
1289
+ await this.#closeClient(client);
1290
+ this.#client = void 0;
1291
+ } finally {
1292
+ try {
1293
+ await this.#releaseProfile();
1294
+ } finally {
1295
+ this.#finish();
1296
+ }
1297
+ }
1298
+ }
1299
+ async #settle() {
1300
+ await this.#connecting?.catch(() => void 0);
1301
+ await this.#disconnecting?.catch(() => void 0);
1302
+ }
1303
+ async #closeRemote(client) {
1304
+ let remote = client;
1305
+ let temporary = false;
1306
+ if (remote === void 0 && this.#owned === true && this.#endpoint !== void 0) {
1307
+ remote = new CDPClient({
1308
+ transport: createCDPTransport({
1309
+ url: this.#endpoint,
1310
+ timeout: this.#timeout()
1311
+ }),
1312
+ timeout: this.#timeout()
1313
+ });
1314
+ try {
1315
+ await remote.connect();
1316
+ temporary = true;
1317
+ } catch {
1318
+ await this.#closeClient(remote);
1319
+ return;
1320
+ }
1321
+ }
1322
+ if (remote !== void 0) try {
1323
+ await remote.send("Browser.close");
1324
+ } catch {}
1325
+ if (temporary) await this.#closeClient(remote);
1326
+ }
1327
+ async #closeContexts() {
1328
+ for (const context of [...this.#contexts]) try {
1329
+ await context.close();
1330
+ } catch {}
1331
+ }
1332
+ async #destroyContexts() {
1333
+ await this.#destroyContextList([...this.#contexts]);
1334
+ }
1335
+ #registerContext(context) {
1336
+ this.#contexts.push(context);
1337
+ context.emitter.on("close", () => {
1338
+ const index = this.#contexts.indexOf(context);
1339
+ if (index >= 0) this.#contexts.splice(index, 1);
1098
1340
  });
1341
+ this.#emitter.emit("context", context);
1342
+ }
1343
+ async #destroyContextList(contexts) {
1344
+ for (const context of contexts) try {
1345
+ await context.destroy();
1346
+ } catch {}
1347
+ }
1348
+ async #closeClient(client) {
1349
+ if (client === void 0) return;
1350
+ try {
1351
+ await client.close();
1352
+ } catch {}
1353
+ }
1354
+ async #releaseProfile() {
1355
+ const profile = this.#profile;
1356
+ this.#profile = void 0;
1357
+ if (profile !== void 0) await removeBrowserProfile(profile);
1358
+ }
1359
+ async #waitForProcess(process) {
1360
+ if (process.exitCode !== null || process.signalCode !== null) return;
1361
+ await once(process, "exit");
1362
+ }
1363
+ async #waitForProcessWithin(process, timeout) {
1364
+ if (process.exitCode !== null || process.signalCode !== null) return true;
1365
+ const signal = AbortSignal.timeout(timeout);
1366
+ try {
1367
+ await once(process, "exit", { signal });
1368
+ return true;
1369
+ } catch (error) {
1370
+ if (signal.aborted) return process.exitCode !== null || process.signalCode !== null;
1371
+ throw error;
1372
+ }
1373
+ }
1374
+ async #terminate(process) {
1375
+ if (process === void 0 || process.exitCode !== null || process.signalCode !== null) return;
1099
1376
  try {
1100
1377
  process.kill("SIGTERM");
1101
1378
  } catch {
1102
1379
  return;
1103
1380
  }
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;
1381
+ if (await this.#waitForProcessWithin(process, 3e3)) return;
1382
+ try {
1383
+ process.kill("SIGKILL");
1384
+ } catch {
1385
+ return;
1114
1386
  }
1387
+ await this.#waitForProcess(process);
1388
+ }
1389
+ #finish() {
1390
+ this.#unbindTransport();
1391
+ this.#unbindProcess();
1392
+ this.#status = "disconnected";
1393
+ this.#connection = void 0;
1394
+ this.#owned = void 0;
1395
+ this.#endpoint = void 0;
1396
+ this.#client = void 0;
1397
+ this.#process = void 0;
1398
+ this.#contexts = [];
1399
+ this.#emitter.emit("destroy");
1400
+ this.#emitter.destroy();
1115
1401
  }
1116
1402
  };
1117
1403
  //#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 };
1404
+ 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_PROFILE_PREFIX, 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, createBrowserProfile, createCDPTransport, createScreenshotWriter, defaultInstallPaths, defaultStoreBases, fetchCDPTargets, findAllEnvOverrides, findAllInStore, findAllInstallPaths, findEnvOverride, findInStore, findInstallPath, findSystemBrowser, findSystemBrowsers, isBrowserConnectionError, isBrowserDestroyedError, isBrowserNotConnectedError, launchBrowserProcess, normalizeExecutablePath, parseBrowserEngine, probeAllPathNames, probePathNames, removeBrowserProfile, waitForCDPReady, windowsRoots };
1119
1405
 
1120
1406
  //# sourceMappingURL=index.js.map