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