@orkestrel/browser 0.0.6 → 0.0.8

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.
@@ -29,7 +29,7 @@ var BROWSER_LAUNCH_ARGS = Object.freeze(["--no-first-run", "--no-default-browser
29
29
  var BROWSER_HEADLESS_ARG = "--headless=new";
30
30
  /** Prefix for isolated browser profiles created beneath the operating-system temp directory. */
31
31
  var BROWSER_PROFILE_PREFIX = "orkestrel-browser-";
32
- /** Grace period after SIGTERM before a launched process is escalated to SIGKILL during teardown. */
32
+ /** Bound for each launched-process exit window during TERM-to-KILL teardown. */
33
33
  var BROWSER_KILL_GRACE_MS = 3e3;
34
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. */
35
35
  var BROWSER_PORT_PROBE_TIMEOUT_MS = 200;
@@ -386,7 +386,8 @@ function findAllInStore(base, platform) {
386
386
  if (existsSync(linkPath)) found.push(linkPath);
387
387
  const glob = BROWSER_STORE_GLOBS[platform];
388
388
  if (glob === void 0) return found;
389
- const matches = globSync(`${base.replaceAll("\\", "/")}/${glob}`).filter((match) => existsSync(match));
389
+ const pattern = `${base.replaceAll("\\", "/")}/${glob}`;
390
+ const matches = globSync(pattern).filter((match) => existsSync(match));
390
391
  matches.sort((a, b) => b.localeCompare(a, void 0, { numeric: true }));
391
392
  found.push(...matches);
392
393
  return found;
@@ -394,6 +395,10 @@ function findAllInStore(base, platform) {
394
395
  /**
395
396
  * Launch a browser process with raw-CDP debugging flags.
396
397
  *
398
+ * @remarks
399
+ * POSIX launches own an isolated process group so lifecycle teardown can
400
+ * signal and await every Chromium subprocess without affecting the caller.
401
+ *
397
402
  * @param executable - Absolute path to the browser executable
398
403
  * @param port - Port the browser exposes its CDP endpoint on
399
404
  * @param headless - Whether to launch in headless mode
@@ -407,7 +412,10 @@ function launchBrowserProcess(executable, port, headless, profile, extra) {
407
412
  args.push(`--remote-debugging-port=${port}`, ...BROWSER_LAUNCH_ARGS);
408
413
  if (headless) args.push(BROWSER_HEADLESS_ARG);
409
414
  if (profile !== void 0) args.push(`--user-data-dir=${profile}`);
410
- return spawn(executable, args, { stdio: "ignore" });
415
+ return spawn(executable, args, {
416
+ stdio: "ignore",
417
+ detached: process.platform !== "win32"
418
+ });
411
419
  }
412
420
  /**
413
421
  * Poll a browser's CDP version endpoint until it responds or the timeout elapses.
@@ -498,11 +506,11 @@ var WebSocketCDPTransport = class {
498
506
  #reject;
499
507
  #timer;
500
508
  #closeResolve;
501
- #onSocketClose = () => this.#closeResolve?.();
509
+ #onSocketClose = this.#handleSocketClose.bind(this);
502
510
  constructor(options) {
503
511
  this.#emitter = new Emitter({
504
- on: options.on,
505
- error: options.error
512
+ ...options.on !== void 0 ? { on: options.on } : {},
513
+ ...options.error !== void 0 ? { error: options.error } : {}
506
514
  });
507
515
  this.#url = options.url;
508
516
  this.#timeout = options.timeout ?? BROWSER_DEFAULT_TIMEOUT_MS;
@@ -673,6 +681,9 @@ var WebSocketCDPTransport = class {
673
681
  });
674
682
  socket.emitter.on("error", (error) => this.#emitter.emit("error", error));
675
683
  }
684
+ #handleSocketClose() {
685
+ this.#closeResolve?.();
686
+ }
676
687
  };
677
688
  //#endregion
678
689
  //#region src/server/factories.ts
@@ -736,15 +747,16 @@ var Browser = class {
736
747
  #destroyed = false;
737
748
  #connecting;
738
749
  #disconnecting;
750
+ #exitCleanup;
739
751
  #shutdown;
740
752
  #transport;
741
- #onTransportClose = () => this.#handleTransportLoss();
742
- #onTransportError = () => this.#handleTransportLoss();
743
- #onProcessExit = () => this.#handleProcessExit();
753
+ #onTransportClose = this.#handleTransportLoss.bind(this);
754
+ #onTransportError = this.#handleTransportLoss.bind(this);
755
+ #onProcessExit = this.#handleProcessExit.bind(this);
744
756
  constructor(options) {
745
757
  this.#emitter = new Emitter({
746
- on: options?.on,
747
- error: options?.error
758
+ ...options?.on !== void 0 ? { on: options.on } : {},
759
+ ...options?.error !== void 0 ? { error: options.error } : {}
748
760
  });
749
761
  this.#options = options ?? {};
750
762
  this.#engine = this.#options.engine ?? (this.#options.executable !== void 0 ? parseBrowserEngine(this.#options.executable) ?? "chromium" : "chromium");
@@ -780,14 +792,12 @@ var Browser = class {
780
792
  }
781
793
  async connect() {
782
794
  if (this.#destroyed) throw new BrowserDestroyedError();
783
- if (this.#disconnecting !== void 0) await this.#disconnecting;
784
- if (this.#destroyed) throw new BrowserDestroyedError();
785
- if (this.#status === "connected") return;
786
795
  const active = this.#connecting;
787
796
  if (active !== void 0) {
788
797
  await active;
789
798
  return;
790
799
  }
800
+ if (this.#status === "connected") return;
791
801
  const attempt = this.#establish();
792
802
  this.#connecting = attempt;
793
803
  try {
@@ -803,15 +813,11 @@ var Browser = class {
803
813
  }
804
814
  async disconnect() {
805
815
  if (this.#destroyed) return;
806
- const active = this.#disconnecting;
807
- if (active !== void 0) {
808
- await active;
809
- return;
810
- }
811
- if (this.#connecting !== void 0) {
812
- await this.#connecting.catch(() => void 0);
813
- if (this.#destroyed) return;
814
- }
816
+ const disconnecting = this.#disconnecting;
817
+ const connecting = this.#connecting;
818
+ if (disconnecting !== void 0) await disconnecting;
819
+ if (connecting !== void 0) await connecting.catch(() => void 0);
820
+ if (this.#destroyed) return;
815
821
  if (this.#status !== "connected" || this.#client === void 0) return;
816
822
  const attempt = this.#detach();
817
823
  this.#disconnecting = attempt;
@@ -892,6 +898,10 @@ var Browser = class {
892
898
  return shutdown;
893
899
  }
894
900
  async #establish() {
901
+ if (this.#disconnecting !== void 0) await this.#disconnecting;
902
+ await this.#settleExit();
903
+ if (this.#destroyed) throw new BrowserDestroyedError();
904
+ if (this.#status === "connected") return;
895
905
  this.#assertNotAborted();
896
906
  this.#status = "connecting";
897
907
  try {
@@ -965,18 +975,19 @@ var Browser = class {
965
975
  const connected = this.#status === "connected";
966
976
  const client = this.#client;
967
977
  const contexts = this.#contexts;
978
+ const process = this.#process;
968
979
  this.#unbindTransport();
969
980
  this.#unbindProcess();
970
981
  this.#process = void 0;
971
- this.#releaseProfile().catch((error) => this.#emitter.emit("error", error));
982
+ const cleanup = this.#cleanupExitedProcess(process, contexts, client);
983
+ this.#exitCleanup = cleanup;
984
+ cleanup.catch((error) => this.#emitter.emit("error", error));
972
985
  this.#client = void 0;
973
986
  this.#contexts = [];
974
987
  this.#connection = void 0;
975
988
  this.#owned = void 0;
976
989
  this.#endpoint = void 0;
977
990
  if (connected) this.#status = "disconnected";
978
- this.#destroyContextList(contexts);
979
- this.#closeClient(client);
980
991
  this.#emitter.emit("error", new BrowserConnectionError("The browser process exited unexpectedly", { cause: BROWSER_PROCESS_EXIT_CAUSE }));
981
992
  if (connected) {
982
993
  this.#emitter.emit("disconnect");
@@ -1136,7 +1147,7 @@ var Browser = class {
1136
1147
  else {
1137
1148
  const found = findSystemBrowser({
1138
1149
  ...this.#options.browsers,
1139
- engine: requestedEngine
1150
+ ...requestedEngine !== void 0 ? { engine: requestedEngine } : {}
1140
1151
  });
1141
1152
  executable = found?.executable;
1142
1153
  resolvedEngine = found?.engine;
@@ -1250,6 +1261,7 @@ var Browser = class {
1250
1261
  this.#registerContext(context);
1251
1262
  }
1252
1263
  async #destroyResources() {
1264
+ let terminated = false;
1253
1265
  try {
1254
1266
  await this.#settle();
1255
1267
  this.#unbindTransport();
@@ -1262,17 +1274,22 @@ var Browser = class {
1262
1274
  this.#contexts = [];
1263
1275
  await this.#terminate(this.#process);
1264
1276
  this.#process = void 0;
1265
- await this.#closeClient(client);
1266
- this.#client = void 0;
1277
+ terminated = true;
1267
1278
  } finally {
1279
+ const contexts = this.#contexts;
1280
+ this.#contexts = [];
1281
+ const client = this.#client;
1282
+ this.#client = void 0;
1268
1283
  try {
1269
- await this.#releaseProfile();
1284
+ await this.#cleanupLocal(contexts, client);
1285
+ if (terminated) await this.#releaseProfile();
1270
1286
  } finally {
1271
1287
  this.#finish();
1272
1288
  }
1273
1289
  }
1274
1290
  }
1275
1291
  async #closeResources() {
1292
+ let terminated = false;
1276
1293
  try {
1277
1294
  await this.#settle();
1278
1295
  this.#unbindTransport();
@@ -1283,14 +1300,18 @@ var Browser = class {
1283
1300
  await this.#closeRemote(client);
1284
1301
  const process = this.#process;
1285
1302
  if (process !== void 0) {
1286
- if (!await this.#waitForProcessWithin(process, 3e3)) await this.#terminate(process);
1303
+ if (!await this.#waitForTerminationWithin(process, 3e3)) await this.#terminate(process);
1287
1304
  }
1288
1305
  this.#process = void 0;
1289
- await this.#closeClient(client);
1290
- this.#client = void 0;
1306
+ terminated = true;
1291
1307
  } finally {
1308
+ const contexts = this.#contexts;
1309
+ this.#contexts = [];
1310
+ const client = this.#client;
1311
+ this.#client = void 0;
1292
1312
  try {
1293
- await this.#releaseProfile();
1313
+ await this.#cleanupLocal(contexts, client);
1314
+ if (terminated) await this.#releaseProfile();
1294
1315
  } finally {
1295
1316
  this.#finish();
1296
1317
  }
@@ -1299,16 +1320,24 @@ var Browser = class {
1299
1320
  async #settle() {
1300
1321
  await this.#connecting?.catch(() => void 0);
1301
1322
  await this.#disconnecting?.catch(() => void 0);
1323
+ await this.#settleExit();
1324
+ }
1325
+ async #settleExit() {
1326
+ const cleanup = this.#exitCleanup;
1327
+ if (cleanup === void 0) return;
1328
+ await cleanup;
1329
+ if (this.#exitCleanup === cleanup) this.#exitCleanup = void 0;
1302
1330
  }
1303
1331
  async #closeRemote(client) {
1304
1332
  let remote = client;
1305
1333
  let temporary = false;
1306
1334
  if (remote === void 0 && this.#owned === true && this.#endpoint !== void 0) {
1335
+ const transport = createCDPTransport({
1336
+ url: this.#endpoint,
1337
+ timeout: this.#timeout()
1338
+ });
1307
1339
  remote = new CDPClient({
1308
- transport: createCDPTransport({
1309
- url: this.#endpoint,
1310
- timeout: this.#timeout()
1311
- }),
1340
+ transport,
1312
1341
  timeout: this.#timeout()
1313
1342
  });
1314
1343
  try {
@@ -1356,9 +1385,19 @@ var Browser = class {
1356
1385
  this.#profile = void 0;
1357
1386
  if (profile !== void 0) await removeBrowserProfile(profile);
1358
1387
  }
1359
- async #waitForProcess(process) {
1360
- if (process.exitCode !== null || process.signalCode !== null) return;
1361
- await once(process, "exit");
1388
+ async #cleanupExitedProcess(process, contexts, client) {
1389
+ let terminated = false;
1390
+ try {
1391
+ await this.#terminate(process);
1392
+ terminated = true;
1393
+ } finally {
1394
+ await this.#cleanupLocal(contexts, client);
1395
+ }
1396
+ if (terminated) await this.#releaseProfile();
1397
+ }
1398
+ async #cleanupLocal(contexts, client) {
1399
+ await this.#destroyContextList(contexts);
1400
+ await this.#closeClient(client);
1362
1401
  }
1363
1402
  async #waitForProcessWithin(process, timeout) {
1364
1403
  if (process.exitCode !== null || process.signalCode !== null) return true;
@@ -1371,20 +1410,55 @@ var Browser = class {
1371
1410
  throw error;
1372
1411
  }
1373
1412
  }
1374
- async #terminate(process) {
1375
- if (process === void 0 || process.exitCode !== null || process.signalCode !== null) return;
1413
+ #hasProcessGroup(process) {
1414
+ const pid = process.pid;
1415
+ return globalThis.process.platform !== "win32" && pid !== void 0 && pid > 0;
1416
+ }
1417
+ #inspectProcessGroup(process) {
1418
+ if (!this.#hasProcessGroup(process)) return false;
1419
+ return this.#signalProcess(process, 0);
1420
+ }
1421
+ #signalProcess(process, signal) {
1422
+ const pid = process.pid;
1376
1423
  try {
1377
- process.kill("SIGTERM");
1378
- } catch {
1379
- return;
1424
+ if (this.#hasProcessGroup(process) && pid !== void 0) return globalThis.process.kill(-pid, signal);
1425
+ return process.kill(signal);
1426
+ } catch (error) {
1427
+ const code = isError(error) && "code" in error && isString(error.code) ? error.code : void 0;
1428
+ if (code === "ESRCH") return false;
1429
+ if (signal === 0) return code === "EPERM" ? true : void 0;
1430
+ throw new BrowserConnectionError("Failed to signal the browser process", {
1431
+ pid,
1432
+ signal,
1433
+ cause: error
1434
+ });
1380
1435
  }
1381
- if (await this.#waitForProcessWithin(process, 3e3)) return;
1382
- try {
1383
- process.kill("SIGKILL");
1384
- } catch {
1385
- return;
1436
+ }
1437
+ async #waitForProcessGroupWithin(process, timeout) {
1438
+ const deadline = performance.now() + timeout;
1439
+ let confirmed = false;
1440
+ while (true) {
1441
+ const alive = this.#inspectProcessGroup(process);
1442
+ if (alive === false) return true;
1443
+ if (alive === true) confirmed = true;
1444
+ const remaining = deadline - performance.now();
1445
+ if (remaining <= 0) return confirmed ? false : void 0;
1446
+ await new Promise((resolve) => setTimeout(resolve, Math.min(BROWSER_WAIT_POLL_INTERVAL_MS, remaining)));
1386
1447
  }
1387
- await this.#waitForProcess(process);
1448
+ }
1449
+ async #waitForTerminationWithin(process, timeout, final = false) {
1450
+ const exited = this.#waitForProcessWithin(process, timeout);
1451
+ const drained = this.#waitForProcessGroupWithin(process, timeout);
1452
+ const results = await Promise.all([exited, drained]);
1453
+ return results[0] && (results[1] === true || final && results[1] === void 0);
1454
+ }
1455
+ async #terminate(process) {
1456
+ if (process === void 0 || (process.exitCode !== null || process.signalCode !== null) && this.#inspectProcessGroup(process) === false) return;
1457
+ if (this.#signalProcess(process, "SIGTERM") === false) return;
1458
+ if (await this.#waitForTerminationWithin(process, 3e3)) return;
1459
+ if (this.#signalProcess(process, "SIGKILL") === false) return;
1460
+ if (await this.#waitForTerminationWithin(process, 3e3, true)) return;
1461
+ throw new BrowserConnectionError("Browser process did not exit after SIGKILL", { pid: process.pid });
1388
1462
  }
1389
1463
  #finish() {
1390
1464
  this.#unbindTransport();
@@ -1395,6 +1469,7 @@ var Browser = class {
1395
1469
  this.#endpoint = void 0;
1396
1470
  this.#client = void 0;
1397
1471
  this.#process = void 0;
1472
+ this.#exitCleanup = void 0;
1398
1473
  this.#contexts = [];
1399
1474
  this.#emitter.emit("destroy");
1400
1475
  this.#emitter.destroy();