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