@orkestrel/browser 0.0.5 → 0.0.7

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,16 +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
8
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
6
- import { isRecord, isString } from "@orkestrel/contract";
7
9
  import { randomBytes } from "node:crypto";
8
10
  import { request } from "node:http";
9
11
  import { request as request$1 } from "node:https";
10
12
  import { Emitter } from "@orkestrel/emitter";
11
13
  import { WEBSOCKET_READY_CLOSED, WEBSOCKET_READY_OPEN, WEBSOCKET_VERSION, computeWebSocketAccept, createNodeWebSocket } from "@orkestrel/websocket";
12
14
  import { addAbortListener, once } from "node:events";
13
- import { mkdir, writeFile } from "node:fs/promises";
14
15
  //#region src/server/constants.ts
15
16
  /** Default CDP port probed for an existing browser and used for launches. */
16
17
  var BROWSER_DEFAULT_CDP_PORT = 9222;
@@ -26,7 +27,9 @@ var BROWSER_CDP_LIST_PATH = "/json/list";
26
27
  var BROWSER_LAUNCH_ARGS = Object.freeze(["--no-first-run", "--no-default-browser-check"]);
27
28
  /** Flag enabling headless mode on a launched browser process. */
28
29
  var BROWSER_HEADLESS_ARG = "--headless=new";
29
- /** Grace period after SIGTERM before a launched process is escalated to SIGKILL during teardown. */
30
+ /** Prefix for isolated browser profiles created beneath the operating-system temp directory. */
31
+ var BROWSER_PROFILE_PREFIX = "orkestrel-browser-";
32
+ /** Bound for each launched-process exit window during TERM-to-KILL teardown. */
30
33
  var BROWSER_KILL_GRACE_MS = 3e3;
31
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. */
32
35
  var BROWSER_PORT_PROBE_TIMEOUT_MS = 200;
@@ -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
@@ -263,6 +266,44 @@ function browserToEngine(browser) {
263
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];
@@ -353,6 +394,10 @@ function findAllInStore(base, platform) {
353
394
  /**
354
395
  * Launch a browser process with raw-CDP debugging flags.
355
396
  *
397
+ * @remarks
398
+ * POSIX launches own an isolated process group so lifecycle teardown can
399
+ * signal and await every Chromium subprocess without affecting the caller.
400
+ *
356
401
  * @param executable - Absolute path to the browser executable
357
402
  * @param port - Port the browser exposes its CDP endpoint on
358
403
  * @param headless - Whether to launch in headless mode
@@ -366,7 +411,10 @@ function launchBrowserProcess(executable, port, headless, profile, extra) {
366
411
  args.push(`--remote-debugging-port=${port}`, ...BROWSER_LAUNCH_ARGS);
367
412
  if (headless) args.push(BROWSER_HEADLESS_ARG);
368
413
  if (profile !== void 0) args.push(`--user-data-dir=${profile}`);
369
- return spawn(executable, args, { stdio: "ignore" });
414
+ return spawn(executable, args, {
415
+ stdio: "ignore",
416
+ detached: process.platform !== "win32"
417
+ });
370
418
  }
371
419
  /**
372
420
  * Poll a browser's CDP version endpoint until it responds or the timeout elapses.
@@ -415,7 +463,7 @@ async function fetchCDPTargets(port, timeout, host = BROWSER_DEFAULT_HOST) {
415
463
  const response = await fetch(url, { signal: controller.signal });
416
464
  if (!response.ok) return [];
417
465
  const list = await response.json();
418
- if (!Array.isArray(list)) return [];
466
+ if (!isArray(list)) return [];
419
467
  const targets = [];
420
468
  for (const entry of list) {
421
469
  if (!isRecord(entry)) continue;
@@ -457,11 +505,11 @@ var WebSocketCDPTransport = class {
457
505
  #reject;
458
506
  #timer;
459
507
  #closeResolve;
460
- #onSocketClose = () => this.#closeResolve?.();
508
+ #onSocketClose = this.#handleSocketClose.bind(this);
461
509
  constructor(options) {
462
510
  this.#emitter = new Emitter({
463
- on: options.on,
464
- error: options.error
511
+ ...options.on !== void 0 ? { on: options.on } : {},
512
+ ...options.error !== void 0 ? { error: options.error } : {}
465
513
  });
466
514
  this.#url = options.url;
467
515
  this.#timeout = options.timeout ?? BROWSER_DEFAULT_TIMEOUT_MS;
@@ -479,12 +527,12 @@ var WebSocketCDPTransport = class {
479
527
  }
480
528
  this.#socket?.destroy();
481
529
  this.#socket = void 0;
482
- const attempt = this.#open();
483
- this.#starting = attempt;
530
+ const transition = this.#open();
531
+ this.#starting = transition;
484
532
  try {
485
- await attempt;
533
+ await transition;
486
534
  } finally {
487
- if (this.#starting === attempt) this.#starting = void 0;
535
+ if (this.#starting === transition) this.#starting = void 0;
488
536
  }
489
537
  }
490
538
  async send(data) {
@@ -498,16 +546,21 @@ var WebSocketCDPTransport = class {
498
546
  await active;
499
547
  return;
500
548
  }
501
- const attempt = this.#stop();
502
- this.#closing = attempt;
549
+ const transition = this.#stop();
550
+ this.#closing = transition;
503
551
  try {
504
- await attempt;
552
+ await transition;
505
553
  } finally {
506
- if (this.#closing === attempt) this.#closing = void 0;
554
+ if (this.#closing === transition) this.#closing = void 0;
507
555
  }
508
556
  }
509
557
  async #open() {
510
- const url = new URL(this.#url);
558
+ const parsed = attempt(() => new URL(this.#url));
559
+ if (!parsed.success) throw new BrowserConnectionError(`WebSocket CDP URL is invalid: ${this.#url}`, {
560
+ url: this.#url,
561
+ error: parsed.error
562
+ });
563
+ const url = parsed.value;
511
564
  if (url.protocol !== "ws:" && url.protocol !== "wss:") throw new BrowserConnectionError(`WebSocket CDP connection requires a ws: or wss: URL: ${this.#url}`, { url: this.#url });
512
565
  const key = randomBytes(16).toString("base64");
513
566
  const deferred = Promise.withResolvers();
@@ -627,6 +680,9 @@ var WebSocketCDPTransport = class {
627
680
  });
628
681
  socket.emitter.on("error", (error) => this.#emitter.emit("error", error));
629
682
  }
683
+ #handleSocketClose() {
684
+ this.#closeResolve?.();
685
+ }
630
686
  };
631
687
  //#endregion
632
688
  //#region src/server/factories.ts
@@ -669,6 +725,9 @@ function createScreenshotWriter() {
669
725
  }
670
726
  //#endregion
671
727
  //#region src/server/Browser.ts
728
+ /**
729
+ * Discovers, launches, connects to, and owns Chromium-family browser sessions.
730
+ */
672
731
  var Browser = class {
673
732
  #emitter;
674
733
  #options;
@@ -682,19 +741,21 @@ var Browser = class {
682
741
  #endpoint;
683
742
  #client;
684
743
  #process;
744
+ #profile;
685
745
  #contexts = [];
686
746
  #destroyed = false;
687
747
  #connecting;
688
748
  #disconnecting;
749
+ #exitCleanup;
689
750
  #shutdown;
690
751
  #transport;
691
- #onTransportClose = () => this.#handleTransportLoss();
692
- #onTransportError = () => this.#handleTransportLoss();
693
- #onProcessExit = () => this.#handleProcessExit();
752
+ #onTransportClose = this.#handleTransportLoss.bind(this);
753
+ #onTransportError = this.#handleTransportLoss.bind(this);
754
+ #onProcessExit = this.#handleProcessExit.bind(this);
694
755
  constructor(options) {
695
756
  this.#emitter = new Emitter({
696
- on: options?.on,
697
- error: options?.error
757
+ ...options?.on !== void 0 ? { on: options.on } : {},
758
+ ...options?.error !== void 0 ? { error: options.error } : {}
698
759
  });
699
760
  this.#options = options ?? {};
700
761
  this.#engine = this.#options.engine ?? (this.#options.executable !== void 0 ? parseBrowserEngine(this.#options.executable) ?? "chromium" : "chromium");
@@ -730,14 +791,12 @@ var Browser = class {
730
791
  }
731
792
  async connect() {
732
793
  if (this.#destroyed) throw new BrowserDestroyedError();
733
- if (this.#disconnecting !== void 0) await this.#disconnecting;
734
- if (this.#destroyed) throw new BrowserDestroyedError();
735
- if (this.#status === "connected") return;
736
794
  const active = this.#connecting;
737
795
  if (active !== void 0) {
738
796
  await active;
739
797
  return;
740
798
  }
799
+ if (this.#status === "connected") return;
741
800
  const attempt = this.#establish();
742
801
  this.#connecting = attempt;
743
802
  try {
@@ -753,15 +812,11 @@ var Browser = class {
753
812
  }
754
813
  async disconnect() {
755
814
  if (this.#destroyed) return;
756
- const active = this.#disconnecting;
757
- if (active !== void 0) {
758
- await active;
759
- return;
760
- }
761
- if (this.#connecting !== void 0) {
762
- await this.#connecting.catch(() => void 0);
763
- if (this.#destroyed) return;
764
- }
815
+ const disconnecting = this.#disconnecting;
816
+ const connecting = this.#connecting;
817
+ if (disconnecting !== void 0) await disconnecting;
818
+ if (connecting !== void 0) await connecting.catch(() => void 0);
819
+ if (this.#destroyed) return;
765
820
  if (this.#status !== "connected" || this.#client === void 0) return;
766
821
  const attempt = this.#detach();
767
822
  this.#disconnecting = attempt;
@@ -779,6 +834,35 @@ var Browser = class {
779
834
  contexts() {
780
835
  return [...this.#contexts];
781
836
  }
837
+ async isolate(options) {
838
+ if (this.#destroyed) throw new BrowserDestroyedError();
839
+ const client = this.#client;
840
+ if (this.#status !== "connected" || client === void 0) throw new BrowserNotConnectedError();
841
+ validateBrowserContextOptions(options);
842
+ const params = { disposeOnDetach: false };
843
+ if (options?.proxy !== void 0) {
844
+ params["proxyServer"] = options.proxy.server;
845
+ if (options.proxy.bypass !== void 0) params["proxyBypassList"] = options.proxy.bypass.join(",");
846
+ }
847
+ if (options?.origins !== void 0) params["originsWithUniversalNetworkAccess"] = [...options.origins];
848
+ const result = await client.send("Target.createBrowserContext", params);
849
+ if (!isRecord(result) || !isString(result["browserContextId"])) throw new BrowserConnectionError("Failed to create isolated browser context");
850
+ const id = result["browserContextId"];
851
+ const context = new BrowserContext(client, id, options?.emulation?.viewport ?? this.#options.viewport, createScreenshotWriter(), options?.emulation, options?.downloads);
852
+ try {
853
+ if (options?.downloads !== void 0) await client.send("Browser.setDownloadBehavior", {
854
+ behavior: options.downloads.named === true ? "allowAndName" : "allow",
855
+ browserContextId: id,
856
+ downloadPath: options.downloads.path,
857
+ eventsEnabled: true
858
+ });
859
+ } catch (error) {
860
+ await context.close().catch(() => void 0);
861
+ throw error;
862
+ }
863
+ this.#registerContext(context);
864
+ return context;
865
+ }
782
866
  async create(options) {
783
867
  if (this.#destroyed) throw new BrowserDestroyedError();
784
868
  const client = this.#client;
@@ -786,7 +870,7 @@ var Browser = class {
786
870
  let context = this.#contexts[0];
787
871
  if (context === void 0) {
788
872
  context = new BrowserContext(client, void 0, this.#options.viewport, createScreenshotWriter());
789
- this.#contexts.push(context);
873
+ this.#registerContext(context);
790
874
  }
791
875
  const page = await context.create(options);
792
876
  this.#emitter.emit("page", page);
@@ -813,6 +897,10 @@ var Browser = class {
813
897
  return shutdown;
814
898
  }
815
899
  async #establish() {
900
+ if (this.#disconnecting !== void 0) await this.#disconnecting;
901
+ await this.#settleExit();
902
+ if (this.#destroyed) throw new BrowserDestroyedError();
903
+ if (this.#status === "connected") return;
816
904
  this.#assertNotAborted();
817
905
  this.#status = "connecting";
818
906
  try {
@@ -839,8 +927,8 @@ var Browser = class {
839
927
  if (this.#destroyed) throw new BrowserDestroyedError();
840
928
  this.#status = "error";
841
929
  this.#emitter.emit("error", error);
842
- if (error instanceof BrowserConnectionError) throw error;
843
- throw new BrowserConnectionError(error instanceof Error ? error.message : String(error), { executable: this.#options.executable });
930
+ if (isBrowserConnectionError(error)) throw error;
931
+ throw new BrowserConnectionError(isError(error) ? error.message : String(error), { executable: this.#options.executable });
844
932
  }
845
933
  }
846
934
  async #detach() {
@@ -886,17 +974,19 @@ var Browser = class {
886
974
  const connected = this.#status === "connected";
887
975
  const client = this.#client;
888
976
  const contexts = this.#contexts;
977
+ const process = this.#process;
889
978
  this.#unbindTransport();
890
979
  this.#unbindProcess();
891
980
  this.#process = void 0;
981
+ const cleanup = this.#cleanupExitedProcess(process, contexts, client);
982
+ this.#exitCleanup = cleanup;
983
+ cleanup.catch((error) => this.#emitter.emit("error", error));
892
984
  this.#client = void 0;
893
985
  this.#contexts = [];
894
986
  this.#connection = void 0;
895
987
  this.#owned = void 0;
896
988
  this.#endpoint = void 0;
897
989
  if (connected) this.#status = "disconnected";
898
- this.#destroyContextList(contexts);
899
- this.#closeClient(client);
900
990
  this.#emitter.emit("error", new BrowserConnectionError("The browser process exited unexpectedly", { cause: BROWSER_PROCESS_EXIT_CAUSE }));
901
991
  if (connected) {
902
992
  this.#emitter.emit("disconnect");
@@ -1056,15 +1146,23 @@ var Browser = class {
1056
1146
  else {
1057
1147
  const found = findSystemBrowser({
1058
1148
  ...this.#options.browsers,
1059
- engine: requestedEngine
1149
+ ...requestedEngine !== void 0 ? { engine: requestedEngine } : {}
1060
1150
  });
1061
1151
  executable = found?.executable;
1062
1152
  resolvedEngine = found?.engine;
1063
1153
  }
1064
1154
  if (executable === void 0) throw new BrowserConnectionError("No Chromium browser found. Install Chrome, Edge, or Chromium.", requestedEngine === void 0 ? void 0 : { engine: requestedEngine });
1065
1155
  this.#engine = resolvedEngine ?? "chromium";
1066
- const profile = this.#options.profile;
1067
- const process = launchBrowserProcess(executable, this.#cdpPort, this.#options.headless ?? true, profile, this.#options.args);
1156
+ await this.#releaseProfile();
1157
+ const profile = await createBrowserProfile(this.#options.profile);
1158
+ this.#profile = profile;
1159
+ let process;
1160
+ try {
1161
+ process = launchBrowserProcess(executable, this.#cdpPort, this.#options.headless ?? true, profile.path, this.#options.args);
1162
+ } catch (error) {
1163
+ await this.#releaseProfile();
1164
+ throw error;
1165
+ }
1068
1166
  this.#process = process;
1069
1167
  let client;
1070
1168
  try {
@@ -1078,7 +1176,7 @@ var Browser = class {
1078
1176
  timeout: this.#timeout()
1079
1177
  });
1080
1178
  await this.#raceAbort(client.connect());
1081
- const connection = profile === void 0 ? "launch" : "persistent";
1179
+ const connection = this.#options.profile === void 0 ? "launch" : "persistent";
1082
1180
  this.#client = client;
1083
1181
  this.#endpoint = endpoint;
1084
1182
  this.#connection = connection;
@@ -1094,6 +1192,7 @@ var Browser = class {
1094
1192
  this.#unbindProcess();
1095
1193
  await this.#closeClient(client);
1096
1194
  await this.#terminate(process);
1195
+ await this.#releaseProfile();
1097
1196
  this.#process = void 0;
1098
1197
  this.#client = void 0;
1099
1198
  this.#endpoint = void 0;
@@ -1110,16 +1209,16 @@ var Browser = class {
1110
1209
  const signal = AbortSignal.any([controller.signal, this.#signal()]);
1111
1210
  const ready = waitForCDPReady(this.#cdpPort, this.#timeout(), this.#cdpHost, signal);
1112
1211
  const exited = once(process, "exit", { signal }).then((values) => {
1113
- const code = typeof values[0] === "number" ? values[0] : null;
1114
- const exitSignal = typeof values[1] === "string" ? values[1] : null;
1212
+ const code = isInteger(values[0]) ? values[0] : null;
1213
+ const exitSignal = isString(values[1]) ? values[1] : null;
1115
1214
  throw new BrowserConnectionError(this.#formatLaunchExit(code, exitSignal), context);
1116
1215
  });
1117
1216
  try {
1118
1217
  return await Promise.race([ready, exited]);
1119
1218
  } catch (error) {
1120
- if (error instanceof BrowserConnectionError) throw error;
1219
+ if (isBrowserConnectionError(error)) throw error;
1121
1220
  if (this.#signal().aborted) throw new BrowserConnectionError("Connection aborted", context);
1122
- throw new BrowserConnectionError(error instanceof Error ? error.message : String(error), context);
1221
+ throw new BrowserConnectionError(isError(error) ? error.message : String(error), context);
1123
1222
  } finally {
1124
1223
  controller.abort();
1125
1224
  ready.catch(() => void 0);
@@ -1144,7 +1243,7 @@ var Browser = class {
1144
1243
  } catch {
1145
1244
  return;
1146
1245
  }
1147
- if (!isRecord(result) || !Array.isArray(result["targetInfos"])) return;
1246
+ if (!isRecord(result) || !isArray(result["targetInfos"])) return;
1148
1247
  const pages = [];
1149
1248
  for (const target of result["targetInfos"]) {
1150
1249
  if (!isRecord(target) || !isString(target["targetId"]) || target["type"] !== "page" || !isString(target["title"]) || !isString(target["url"])) continue;
@@ -1158,9 +1257,10 @@ var Browser = class {
1158
1257
  if (pages.length === 0 || this.#contexts.length > 0) return;
1159
1258
  const context = new BrowserContext(client, void 0, this.#options.viewport, createScreenshotWriter());
1160
1259
  await context.sync(pages);
1161
- this.#contexts.push(context);
1260
+ this.#registerContext(context);
1162
1261
  }
1163
1262
  async #destroyResources() {
1263
+ let terminated = false;
1164
1264
  try {
1165
1265
  await this.#settle();
1166
1266
  this.#unbindTransport();
@@ -1173,13 +1273,22 @@ var Browser = class {
1173
1273
  this.#contexts = [];
1174
1274
  await this.#terminate(this.#process);
1175
1275
  this.#process = void 0;
1176
- await this.#closeClient(client);
1177
- this.#client = void 0;
1276
+ terminated = true;
1178
1277
  } finally {
1179
- this.#finish();
1278
+ const contexts = this.#contexts;
1279
+ this.#contexts = [];
1280
+ const client = this.#client;
1281
+ this.#client = void 0;
1282
+ try {
1283
+ await this.#cleanupLocal(contexts, client);
1284
+ if (terminated) await this.#releaseProfile();
1285
+ } finally {
1286
+ this.#finish();
1287
+ }
1180
1288
  }
1181
1289
  }
1182
1290
  async #closeResources() {
1291
+ let terminated = false;
1183
1292
  try {
1184
1293
  await this.#settle();
1185
1294
  this.#unbindTransport();
@@ -1190,18 +1299,33 @@ var Browser = class {
1190
1299
  await this.#closeRemote(client);
1191
1300
  const process = this.#process;
1192
1301
  if (process !== void 0) {
1193
- if (!await this.#waitForProcessWithin(process, 3e3)) await this.#terminate(process);
1302
+ if (!await this.#waitForTerminationWithin(process, 3e3)) await this.#terminate(process);
1194
1303
  }
1195
1304
  this.#process = void 0;
1196
- await this.#closeClient(client);
1197
- this.#client = void 0;
1305
+ terminated = true;
1198
1306
  } finally {
1199
- this.#finish();
1307
+ const contexts = this.#contexts;
1308
+ this.#contexts = [];
1309
+ const client = this.#client;
1310
+ this.#client = void 0;
1311
+ try {
1312
+ await this.#cleanupLocal(contexts, client);
1313
+ if (terminated) await this.#releaseProfile();
1314
+ } finally {
1315
+ this.#finish();
1316
+ }
1200
1317
  }
1201
1318
  }
1202
1319
  async #settle() {
1203
1320
  await this.#connecting?.catch(() => void 0);
1204
1321
  await this.#disconnecting?.catch(() => void 0);
1322
+ await this.#settleExit();
1323
+ }
1324
+ async #settleExit() {
1325
+ const cleanup = this.#exitCleanup;
1326
+ if (cleanup === void 0) return;
1327
+ await cleanup;
1328
+ if (this.#exitCleanup === cleanup) this.#exitCleanup = void 0;
1205
1329
  }
1206
1330
  async #closeRemote(client) {
1207
1331
  let remote = client;
@@ -1228,12 +1352,20 @@ var Browser = class {
1228
1352
  if (temporary) await this.#closeClient(remote);
1229
1353
  }
1230
1354
  async #closeContexts() {
1231
- for (const context of this.#contexts) try {
1355
+ for (const context of [...this.#contexts]) try {
1232
1356
  await context.close();
1233
1357
  } catch {}
1234
1358
  }
1235
1359
  async #destroyContexts() {
1236
- await this.#destroyContextList(this.#contexts);
1360
+ await this.#destroyContextList([...this.#contexts]);
1361
+ }
1362
+ #registerContext(context) {
1363
+ this.#contexts.push(context);
1364
+ context.emitter.on("close", () => {
1365
+ const index = this.#contexts.indexOf(context);
1366
+ if (index >= 0) this.#contexts.splice(index, 1);
1367
+ });
1368
+ this.#emitter.emit("context", context);
1237
1369
  }
1238
1370
  async #destroyContextList(contexts) {
1239
1371
  for (const context of contexts) try {
@@ -1246,9 +1378,24 @@ var Browser = class {
1246
1378
  await client.close();
1247
1379
  } catch {}
1248
1380
  }
1249
- async #waitForProcess(process) {
1250
- if (process.exitCode !== null || process.signalCode !== null) return;
1251
- await once(process, "exit");
1381
+ async #releaseProfile() {
1382
+ const profile = this.#profile;
1383
+ this.#profile = void 0;
1384
+ if (profile !== void 0) await removeBrowserProfile(profile);
1385
+ }
1386
+ async #cleanupExitedProcess(process, contexts, client) {
1387
+ let terminated = false;
1388
+ try {
1389
+ await this.#terminate(process);
1390
+ terminated = true;
1391
+ } finally {
1392
+ await this.#cleanupLocal(contexts, client);
1393
+ }
1394
+ if (terminated) await this.#releaseProfile();
1395
+ }
1396
+ async #cleanupLocal(contexts, client) {
1397
+ await this.#destroyContextList(contexts);
1398
+ await this.#closeClient(client);
1252
1399
  }
1253
1400
  async #waitForProcessWithin(process, timeout) {
1254
1401
  if (process.exitCode !== null || process.signalCode !== null) return true;
@@ -1261,20 +1408,55 @@ var Browser = class {
1261
1408
  throw error;
1262
1409
  }
1263
1410
  }
1264
- async #terminate(process) {
1265
- if (process === void 0 || process.exitCode !== null || process.signalCode !== null) return;
1411
+ #hasProcessGroup(process) {
1412
+ const pid = process.pid;
1413
+ return globalThis.process.platform !== "win32" && pid !== void 0 && pid > 0;
1414
+ }
1415
+ #inspectProcessGroup(process) {
1416
+ if (!this.#hasProcessGroup(process)) return false;
1417
+ return this.#signalProcess(process, 0);
1418
+ }
1419
+ #signalProcess(process, signal) {
1420
+ const pid = process.pid;
1266
1421
  try {
1267
- process.kill("SIGTERM");
1268
- } catch {
1269
- return;
1422
+ if (this.#hasProcessGroup(process) && pid !== void 0) return globalThis.process.kill(-pid, signal);
1423
+ return process.kill(signal);
1424
+ } catch (error) {
1425
+ const code = isError(error) && "code" in error && isString(error.code) ? error.code : void 0;
1426
+ if (code === "ESRCH") return false;
1427
+ if (signal === 0) return code === "EPERM" ? true : void 0;
1428
+ throw new BrowserConnectionError("Failed to signal the browser process", {
1429
+ pid,
1430
+ signal,
1431
+ cause: error
1432
+ });
1270
1433
  }
1271
- if (await this.#waitForProcessWithin(process, 3e3)) return;
1272
- try {
1273
- process.kill("SIGKILL");
1274
- } catch {
1275
- return;
1434
+ }
1435
+ async #waitForProcessGroupWithin(process, timeout) {
1436
+ const deadline = performance.now() + timeout;
1437
+ let confirmed = false;
1438
+ while (true) {
1439
+ const alive = this.#inspectProcessGroup(process);
1440
+ if (alive === false) return true;
1441
+ if (alive === true) confirmed = true;
1442
+ const remaining = deadline - performance.now();
1443
+ if (remaining <= 0) return confirmed ? false : void 0;
1444
+ await new Promise((resolve) => setTimeout(resolve, Math.min(BROWSER_WAIT_POLL_INTERVAL_MS, remaining)));
1276
1445
  }
1277
- await this.#waitForProcess(process);
1446
+ }
1447
+ async #waitForTerminationWithin(process, timeout, final = false) {
1448
+ const exited = this.#waitForProcessWithin(process, timeout);
1449
+ const drained = this.#waitForProcessGroupWithin(process, timeout);
1450
+ const results = await Promise.all([exited, drained]);
1451
+ return results[0] && (results[1] === true || final && results[1] === void 0);
1452
+ }
1453
+ async #terminate(process) {
1454
+ if (process === void 0 || (process.exitCode !== null || process.signalCode !== null) && this.#inspectProcessGroup(process) === false) return;
1455
+ if (this.#signalProcess(process, "SIGTERM") === false) return;
1456
+ if (await this.#waitForTerminationWithin(process, 3e3)) return;
1457
+ if (this.#signalProcess(process, "SIGKILL") === false) return;
1458
+ if (await this.#waitForTerminationWithin(process, 3e3, true)) return;
1459
+ throw new BrowserConnectionError("Browser process did not exit after SIGKILL", { pid: process.pid });
1278
1460
  }
1279
1461
  #finish() {
1280
1462
  this.#unbindTransport();
@@ -1285,12 +1467,13 @@ var Browser = class {
1285
1467
  this.#endpoint = void 0;
1286
1468
  this.#client = void 0;
1287
1469
  this.#process = void 0;
1470
+ this.#exitCleanup = void 0;
1288
1471
  this.#contexts = [];
1289
1472
  this.#emitter.emit("destroy");
1290
1473
  this.#emitter.destroy();
1291
1474
  }
1292
1475
  };
1293
1476
  //#endregion
1294
- export { BROWSER_CDP_LIST_PATH, BROWSER_CDP_PROTOCOL, BROWSER_CDP_VERSION_PATH, BROWSER_DEFAULT_CDP_PORT, BROWSER_DEFAULT_HOST, BROWSER_ENGINE_HINTS, BROWSER_ENV_PATH_KEYS, BROWSER_EXECUTABLE_NAMES, BROWSER_EXECUTABLE_PATHS, BROWSER_HEADLESS_ARG, BROWSER_KILL_GRACE_MS, BROWSER_LAUNCH_ARGS, BROWSER_PORT_PROBE_TIMEOUT_MS, BROWSER_PROCESS_EXIT_CAUSE, BROWSER_STORE_CACHE_DIRS, BROWSER_STORE_DEFAULT_DIRS, BROWSER_STORE_ENV_KEY, BROWSER_STORE_GLOBS, BROWSER_STORE_LINK_NAME, BROWSER_TRANSPORT_LOSS_CAUSE, BROWSER_TRANSPORT_LOSS_DEFER_MS, BROWSER_WINDOWS_ROOT_FALLBACKS, BROWSER_WINDOWS_SUFFIXES, Browser, BrowserConnectionError, BrowserDestroyedError, BrowserNotConnectedError, WebSocketCDPTransport, browserToEngine, createBrowser, createCDPTransport, createScreenshotWriter, defaultInstallPaths, defaultStoreBases, fetchCDPTargets, findAllEnvOverrides, findAllInStore, findAllInstallPaths, findEnvOverride, findInStore, findInstallPath, findSystemBrowser, findSystemBrowsers, isBrowserConnectionError, isBrowserDestroyedError, isBrowserNotConnectedError, launchBrowserProcess, normalizeExecutablePath, parseBrowserEngine, probeAllPathNames, probePathNames, waitForCDPReady, windowsRoots };
1477
+ 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 };
1295
1478
 
1296
1479
  //# sourceMappingURL=index.js.map