@orkestrel/browser 0.0.12 → 0.0.14

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.
@@ -231,15 +231,17 @@ export declare type BrowserEventMap = {
231
231
  * process exiting on its own) drives the disconnected state, preceded by
232
232
  * a coded `error` — `connect()` on the same instance can reattach afterward.
233
233
  * - `destroy` — release local resources. On a launched browser this closes
234
- * pages/contexts, then kills and awaits the process plus its POSIX process
235
- * group. On an adopted browser it sends `Browser.close`. On a merely
234
+ * pages/contexts, then kills and awaits the process serving the CDP endpoint
235
+ * plus its POSIX process group. On an adopted browser it sends
236
+ * `Browser.close`. On a merely
236
237
  * attached browser this is a LOCAL DETACH ONLY because other clients may
237
238
  * share its targets. Idempotent.
238
239
  * - `close` — graceful REMOTE shutdown: best-effort sends CDP `Browser.close`
239
- * (works whether attached or owned), and when owned also awaits the
240
- * process's exit and POSIX group drain (escalating to a kill only if needed),
241
- * then performs the same local cleanup as `destroy()`. Use this to shut
242
- * down a browser this instance doesn't own but wants to terminate anyway.
240
+ * (works whether attached or owned), and when owned also awaits the exit of
241
+ * the process serving the CDP endpoint plus its POSIX process-group drain
242
+ * (escalating to a kill only if needed), then performs the same local cleanup
243
+ * as `destroy()`. Use this to shut down a browser this instance doesn't own
244
+ * but wants to terminate anyway.
243
245
  *
244
246
  * **Page management:**
245
247
  * - `context(index?)` → one context or first
@@ -261,12 +263,17 @@ export declare interface BrowserInterface {
261
263
  readonly owned: boolean | undefined;
262
264
  readonly connected: boolean;
263
265
  /**
264
- * The process this instance launched, if any, while it is believed alive.
266
+ * The process serving this session's CDP endpoint, if any, while it is
267
+ * believed alive.
265
268
  *
266
269
  * @remarks
267
- * Remains readable after a persistent disconnect (the process keeps
268
- * running, detached from the transport) until `destroy()` or an
269
- * observed process exit clears it.
270
+ * A launch owns the process behind its endpoint rather than the process it
271
+ * spawned. The two are the same until the spawned process is a launcher
272
+ * that re-executes the browser and exits before the endpoint answers —
273
+ * Microsoft Edge on Windows — in which case this reports the process the
274
+ * launcher handed the endpoint to. Remains readable after a persistent
275
+ * disconnect (the process keeps running, detached from the transport)
276
+ * until `destroy()` or an observed process exit clears it.
270
277
  */
271
278
  readonly pid: number | undefined;
272
279
  discover(): Promise<BrowserDiscoveryResult>;
@@ -481,6 +488,11 @@ export declare function isBrowserNotConnectedError(value: unknown): value is Bro
481
488
  * @remarks
482
489
  * POSIX launches own an isolated process group so lifecycle teardown can
483
490
  * signal and await every Chromium subprocess without affecting the caller.
491
+ * Windows launches are not detached and own no group, so teardown there
492
+ * signals one process by identifier: the spawned process, or the process the
493
+ * launcher handed the endpoint to when that spawned process re-executed the
494
+ * browser and exited. Terminating a Chromium browser process takes its own
495
+ * subprocesses with it, so that single signal drains the tree.
484
496
  *
485
497
  * @param executable - Absolute path to the browser executable
486
498
  * @param port - Port the browser exposes its CDP endpoint on
@@ -509,6 +521,25 @@ export declare function probeAllPathNames(names: readonly string[], platform: st
509
521
  /** Probe PATH (`which`/`where`) for the first resolvable command name. */
510
522
  export declare function probePathNames(names: readonly string[], platform: string): string | undefined;
511
523
 
524
+ /**
525
+ * Returns the first non-empty line of a command's output, without its
526
+ * surrounding whitespace.
527
+ *
528
+ * @remarks
529
+ * `where` reports every PATH match on its own CRLF-terminated line, so a
530
+ * carriage return left on the first line would travel into `existsSync` and
531
+ * `spawn` as part of the executable path.
532
+ *
533
+ * @param output - Raw decoded standard output of a command
534
+ * @returns The first line carrying text, or undefined when the output has none
535
+ *
536
+ * @example
537
+ * ```ts
538
+ * readFirstLine('C:\\bin\\chrome.exe\r\nC:\\other\\chrome.exe\r\n') // 'C:\\bin\\chrome.exe'
539
+ * ```
540
+ */
541
+ export declare function readFirstLine(output: string): string | undefined;
542
+
512
543
  /**
513
544
  * Remove a library-owned isolated browser profile.
514
545
  *
@@ -352,12 +352,35 @@ function probeAllPathNames(names, platform) {
352
352
  for (const name of names) {
353
353
  const result = spawnSync(finder, [name], { stdio: "pipe" });
354
354
  if (result.status === 0) {
355
- const output = result.stdout.toString("utf-8").trim().split("\n")[0];
356
- if (output !== void 0 && output.length > 0) found.push(output);
355
+ const executable = readFirstLine(result.stdout.toString("utf-8"));
356
+ if (executable !== void 0) found.push(executable);
357
357
  }
358
358
  }
359
359
  return found;
360
360
  }
361
+ /**
362
+ * Returns the first non-empty line of a command's output, without its
363
+ * surrounding whitespace.
364
+ *
365
+ * @remarks
366
+ * `where` reports every PATH match on its own CRLF-terminated line, so a
367
+ * carriage return left on the first line would travel into `existsSync` and
368
+ * `spawn` as part of the executable path.
369
+ *
370
+ * @param output - Raw decoded standard output of a command
371
+ * @returns The first line carrying text, or undefined when the output has none
372
+ *
373
+ * @example
374
+ * ```ts
375
+ * readFirstLine('C:\\bin\\chrome.exe\r\nC:\\other\\chrome.exe\r\n') // 'C:\\bin\\chrome.exe'
376
+ * ```
377
+ */
378
+ function readFirstLine(output) {
379
+ for (const line of output.split(/\r?\n/)) {
380
+ const text = line.trim();
381
+ if (text.length > 0) return text;
382
+ }
383
+ }
361
384
  /** Build the default Playwright browser store base directories to search for a managed Chromium. */
362
385
  function defaultStoreBases(env, platform) {
363
386
  const bases = [];
@@ -398,6 +421,11 @@ function findAllInStore(base, platform) {
398
421
  * @remarks
399
422
  * POSIX launches own an isolated process group so lifecycle teardown can
400
423
  * signal and await every Chromium subprocess without affecting the caller.
424
+ * Windows launches are not detached and own no group, so teardown there
425
+ * signals one process by identifier: the spawned process, or the process the
426
+ * launcher handed the endpoint to when that spawned process re-executed the
427
+ * browser and exited. Terminating a Chromium browser process takes its own
428
+ * subprocesses with it, so that single signal drains the tree.
401
429
  *
402
430
  * @param executable - Absolute path to the browser executable
403
431
  * @param port - Port the browser exposes its CDP endpoint on
@@ -742,6 +770,7 @@ var Browser = class {
742
770
  #endpoint;
743
771
  #client;
744
772
  #process;
773
+ #servingPid;
745
774
  #profile;
746
775
  #contexts = [];
747
776
  #destroyed = false;
@@ -783,7 +812,7 @@ var Browser = class {
783
812
  return this.#status === "connected";
784
813
  }
785
814
  get pid() {
786
- return this.#process?.pid;
815
+ return this.#servingPid ?? this.#process?.pid;
787
816
  }
788
817
  async discover() {
789
818
  const result = await this.#discoverCDP();
@@ -979,6 +1008,7 @@ var Browser = class {
979
1008
  this.#unbindTransport();
980
1009
  this.#unbindProcess();
981
1010
  this.#process = void 0;
1011
+ this.#servingPid = void 0;
982
1012
  const cleanup = this.#cleanupExitedProcess(process, contexts, client);
983
1013
  this.#exitCleanup = cleanup;
984
1014
  cleanup.catch((error) => this.#emitter.emit("error", error));
@@ -996,27 +1026,19 @@ var Browser = class {
996
1026
  }
997
1027
  #handleTransportLoss() {
998
1028
  if (this.#destroyed || this.#status !== "connected") return;
999
- const process = this.#process;
1000
- if (process !== void 0 && (process.exitCode !== null || process.signalCode !== null)) {
1001
- this.#handleProcessExit();
1029
+ if (this.#process === void 0) {
1030
+ this.#confirmTransportLoss();
1002
1031
  return;
1003
1032
  }
1004
- if (process !== void 0) {
1005
- setTimeout(() => this.#confirmTransportLoss(), 50);
1033
+ if (!this.#alive()) {
1034
+ this.#handleProcessExit();
1006
1035
  return;
1007
1036
  }
1008
- this.#confirmTransportLoss();
1037
+ setTimeout(() => this.#confirmTransportLoss(), 50);
1009
1038
  }
1010
1039
  #confirmTransportLoss() {
1011
1040
  if (this.#destroyed || this.#status !== "connected") return;
1012
- const process = this.#process;
1013
- if (process !== void 0 && (process.exitCode !== null || process.signalCode !== null)) {
1014
- this.#handleProcessExit();
1015
- return;
1016
- }
1017
- if (process?.pid !== void 0) try {
1018
- process.kill(0);
1019
- } catch {
1041
+ if (this.#process !== void 0 && !this.#alive()) {
1020
1042
  this.#handleProcessExit();
1021
1043
  return;
1022
1044
  }
@@ -1053,6 +1075,7 @@ var Browser = class {
1053
1075
  }
1054
1076
  #bindProcess(process) {
1055
1077
  this.#unbindProcess();
1078
+ if (this.#servingPid !== void 0) return;
1056
1079
  process.once("exit", this.#onProcessExit);
1057
1080
  if (process.exitCode !== null || process.signalCode !== null) queueMicrotask(this.#onProcessExit);
1058
1081
  }
@@ -1177,6 +1200,7 @@ var Browser = class {
1177
1200
  timeout: this.#timeout()
1178
1201
  });
1179
1202
  await this.#raceAbort(client.connect());
1203
+ await this.#takeEndpointOwner(process, client);
1180
1204
  const connection = this.#options.profile === void 0 ? "launch" : "persistent";
1181
1205
  this.#client = client;
1182
1206
  this.#endpoint = endpoint;
@@ -1195,12 +1219,49 @@ var Browser = class {
1195
1219
  await this.#terminate(process);
1196
1220
  await this.#releaseProfile();
1197
1221
  this.#process = void 0;
1222
+ this.#servingPid = void 0;
1198
1223
  this.#client = void 0;
1199
1224
  this.#endpoint = void 0;
1200
1225
  this.#owned = void 0;
1201
1226
  throw error;
1202
1227
  }
1203
1228
  }
1229
+ /**
1230
+ * Takes over the process serving the CDP endpoint when the spawned child is
1231
+ * no longer it.
1232
+ *
1233
+ * @remarks
1234
+ * A Windows launcher — Microsoft Edge — re-executes the browser with the
1235
+ * same `--remote-debugging-port` and exits 0 before the endpoint answers, so
1236
+ * the child this instance spawned parents the real tree without being part
1237
+ * of it. Chromium names the process behind the endpoint as the `browser`
1238
+ * entry of `SystemInfo.getProcessInfo`, which identifies it without a
1239
+ * platform branch. An endpoint that names no such process cannot be owned,
1240
+ * so the launch closes it and fails rather than leaking it.
1241
+ */
1242
+ async #takeEndpointOwner(process, client) {
1243
+ if (process.exitCode === null && process.signalCode === null) return;
1244
+ let result;
1245
+ try {
1246
+ result = await client.send("SystemInfo.getProcessInfo");
1247
+ } catch {
1248
+ result = void 0;
1249
+ }
1250
+ let pid;
1251
+ if (isRecord(result) && isArray(result["processInfo"])) for (const entry of result["processInfo"]) {
1252
+ if (!isRecord(entry) || entry["type"] !== "browser" || !isInteger(entry["id"])) continue;
1253
+ pid = entry["id"];
1254
+ break;
1255
+ }
1256
+ if (pid === void 0) {
1257
+ await this.#closeRemote(client);
1258
+ throw new BrowserConnectionError("The browser launcher exited without naming the process serving its CDP endpoint", {
1259
+ executable: this.#options.executable,
1260
+ pid: process.pid
1261
+ });
1262
+ }
1263
+ this.#servingPid = pid;
1264
+ }
1204
1265
  async #waitForLaunch(process, executable, args) {
1205
1266
  const context = {
1206
1267
  executable,
@@ -1212,6 +1273,7 @@ var Browser = class {
1212
1273
  const exited = once(process, "exit", { signal }).then((values) => {
1213
1274
  const code = isInteger(values[0]) ? values[0] : null;
1214
1275
  const exitSignal = isString(values[1]) ? values[1] : null;
1276
+ if (code === 0 && exitSignal === null) return ready;
1215
1277
  throw new BrowserConnectionError(this.#formatLaunchExit(code, exitSignal), context);
1216
1278
  });
1217
1279
  try {
@@ -1274,6 +1336,7 @@ var Browser = class {
1274
1336
  this.#contexts = [];
1275
1337
  await this.#terminate(this.#process);
1276
1338
  this.#process = void 0;
1339
+ this.#servingPid = void 0;
1277
1340
  terminated = true;
1278
1341
  } finally {
1279
1342
  const contexts = this.#contexts;
@@ -1303,6 +1366,7 @@ var Browser = class {
1303
1366
  if (!await this.#waitForTerminationWithin(process, 3e3)) await this.#terminate(process);
1304
1367
  }
1305
1368
  this.#process = void 0;
1369
+ this.#servingPid = void 0;
1306
1370
  terminated = true;
1307
1371
  } finally {
1308
1372
  const contexts = this.#contexts;
@@ -1414,31 +1478,39 @@ var Browser = class {
1414
1478
  const pid = process.pid;
1415
1479
  return globalThis.process.platform !== "win32" && pid !== void 0 && pid > 0;
1416
1480
  }
1417
- #inspectProcessGroup(process) {
1418
- if (!this.#hasProcessGroup(process)) return false;
1481
+ #alive() {
1482
+ const process = this.#process;
1483
+ if (process === void 0) return false;
1484
+ if (this.#servingPid !== void 0) return this.#signalProcess(process, 0) !== false;
1485
+ return process.exitCode === null && process.signalCode === null;
1486
+ }
1487
+ #inspectRemainder(process) {
1488
+ if (!this.#hasProcessGroup(process) && this.#servingPid === void 0) return false;
1419
1489
  return this.#signalProcess(process, 0);
1420
1490
  }
1421
1491
  #signalProcess(process, signal) {
1422
1492
  const pid = process.pid;
1493
+ const serving = this.#servingPid;
1423
1494
  try {
1424
1495
  if (this.#hasProcessGroup(process) && pid !== void 0) return globalThis.process.kill(-pid, signal);
1496
+ if (serving !== void 0) return globalThis.process.kill(serving, signal);
1425
1497
  return process.kill(signal);
1426
1498
  } catch (error) {
1427
1499
  const code = isError(error) && "code" in error && isString(error.code) ? error.code : void 0;
1428
1500
  if (code === "ESRCH") return false;
1429
1501
  if (signal === 0) return code === "EPERM" ? true : void 0;
1430
1502
  throw new BrowserConnectionError("Failed to signal the browser process", {
1431
- pid,
1503
+ pid: serving ?? pid,
1432
1504
  signal,
1433
1505
  cause: error
1434
1506
  });
1435
1507
  }
1436
1508
  }
1437
- async #waitForProcessGroupWithin(process, timeout) {
1509
+ async #waitForRemainderWithin(process, timeout) {
1438
1510
  const deadline = performance.now() + timeout;
1439
1511
  let confirmed = false;
1440
1512
  while (true) {
1441
- const alive = this.#inspectProcessGroup(process);
1513
+ const alive = this.#inspectRemainder(process);
1442
1514
  if (alive === false) return true;
1443
1515
  if (alive === true) confirmed = true;
1444
1516
  const remaining = deadline - performance.now();
@@ -1448,17 +1520,17 @@ var Browser = class {
1448
1520
  }
1449
1521
  async #waitForTerminationWithin(process, timeout, final = false) {
1450
1522
  const exited = this.#waitForProcessWithin(process, timeout);
1451
- const drained = this.#waitForProcessGroupWithin(process, timeout);
1523
+ const drained = this.#waitForRemainderWithin(process, timeout);
1452
1524
  const results = await Promise.all([exited, drained]);
1453
1525
  return results[0] && (results[1] === true || final && results[1] === void 0);
1454
1526
  }
1455
1527
  async #terminate(process) {
1456
- if (process === void 0 || (process.exitCode !== null || process.signalCode !== null) && this.#inspectProcessGroup(process) === false) return;
1528
+ if (process === void 0 || (process.exitCode !== null || process.signalCode !== null) && this.#inspectRemainder(process) === false) return;
1457
1529
  if (this.#signalProcess(process, "SIGTERM") === false) return;
1458
1530
  if (await this.#waitForTerminationWithin(process, 3e3)) return;
1459
1531
  if (this.#signalProcess(process, "SIGKILL") === false) return;
1460
1532
  if (await this.#waitForTerminationWithin(process, 3e3, true)) return;
1461
- throw new BrowserConnectionError("Browser process did not exit after SIGKILL", { pid: process.pid });
1533
+ throw new BrowserConnectionError("Browser process did not exit after SIGKILL", { pid: this.#servingPid ?? process.pid });
1462
1534
  }
1463
1535
  #finish() {
1464
1536
  this.#unbindTransport();
@@ -1469,6 +1541,7 @@ var Browser = class {
1469
1541
  this.#endpoint = void 0;
1470
1542
  this.#client = void 0;
1471
1543
  this.#process = void 0;
1544
+ this.#servingPid = void 0;
1472
1545
  this.#exitCleanup = void 0;
1473
1546
  this.#contexts = [];
1474
1547
  this.#emitter.emit("destroy");
@@ -1476,6 +1549,6 @@ var Browser = class {
1476
1549
  }
1477
1550
  };
1478
1551
  //#endregion
1479
- 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 };
1552
+ 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, readFirstLine, removeBrowserProfile, waitForCDPReady, windowsRoots };
1480
1553
 
1481
1554
  //# sourceMappingURL=index.js.map