@narumitw/pi-chrome-devtools 0.54.0 → 0.54.1

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,9 +1,10 @@
1
1
  import { type ChildProcess, execFile, spawn } from "node:child_process";
2
- import { constants } from "node:fs";
3
- import { access, mkdtemp, readFile, rm } from "node:fs/promises";
2
+ import { access, mkdtemp, rm } from "node:fs/promises";
4
3
  import { createServer } from "node:net";
5
4
  import { tmpdir } from "node:os";
6
- import { delimiter, isAbsolute, join, resolve } from "node:path";
5
+ import { join } from "node:path";
6
+ import { browserCandidateDefinitions, resolveBrowserCandidates } from "./browser-candidates.js";
7
+ import { browserWebSocketIdentity, waitForBrowserStartup } from "./browser-startup.js";
7
8
  import {
8
9
  BROWSER_SHUTDOWN_WAIT_MS,
9
10
  type BrowserCandidate,
@@ -12,7 +13,6 @@ import {
12
13
  DEFAULT_ENDPOINT_RETRY_MS,
13
14
  DEFAULT_ENDPOINT_WAIT_MS,
14
15
  DEFAULT_HTTP_TIMEOUT_MS,
15
- DEVTOOLS_ACTIVE_PORT_FILE,
16
16
  type DevToolsPage,
17
17
  invalidateWebMcpOperations,
18
18
  MANAGED_BROWSER_PROFILE_PREFIX,
@@ -21,17 +21,17 @@ import {
21
21
  } from "./runtime.js";
22
22
  import type { EffectiveBrowserSettings } from "./settings.js";
23
23
 
24
- // Endpoint attachment, managed launch, dynamic-port publication, and shutdown intentionally remain
24
+ // Endpoint attachment, managed launch, selected-port publication, and shutdown intentionally remain
25
25
  // together because they form one ownership state machine behind the testable operations boundary.
26
26
  interface BrowserSpawnOptions {
27
27
  shell: false;
28
- stdio: "ignore";
28
+ stdio: ["ignore", "ignore", "pipe"];
29
29
  }
30
30
 
31
31
  export interface BrowserManagerOperations {
32
32
  access(path: string, mode: number): Promise<void>;
33
33
  mkdtemp(prefix: string): Promise<string>;
34
- readFile(path: string, encoding: "utf8"): Promise<string>;
34
+ allocatePort(host: string): Promise<number>;
35
35
  rm(path: string, options: { recursive: true; force: true }): Promise<void>;
36
36
  fetch(input: string, init?: RequestInit): Promise<Response>;
37
37
  spawn(executable: string, args: string[], options: BrowserSpawnOptions): ChildProcess;
@@ -43,7 +43,7 @@ export interface BrowserManagerOperations {
43
43
  const DEFAULT_BROWSER_MANAGER_OPERATIONS: BrowserManagerOperations = {
44
44
  access,
45
45
  mkdtemp,
46
- readFile,
46
+ allocatePort: allocateBrowserPort,
47
47
  rm: (path, options) => rm(path, options),
48
48
  fetch: (input, init) => fetch(input, init),
49
49
  spawn,
@@ -59,6 +59,7 @@ interface ManagedBrowserScope {
59
59
  controller: AbortController;
60
60
  generation: number;
61
61
  launchPromise?: Promise<void>;
62
+ latestLaunchPromise?: Promise<void>;
62
63
  managedBrowser?: ManagedBrowser;
63
64
  port: number;
64
65
  lastLaunchAttempt?: BrowserLaunchAttempt;
@@ -143,9 +144,15 @@ function launchPromiseForOwner(owner?: object) {
143
144
  return owner ? managedBrowserScope(owner).launchPromise : state.launchPromise;
144
145
  }
145
146
 
147
+ function latestLaunchPromiseForOwner(owner?: object) {
148
+ return owner ? managedBrowserScope(owner).latestLaunchPromise : state.latestLaunchPromise;
149
+ }
150
+
146
151
  function setLaunchPromise(owner: object | undefined, launchPromise: Promise<void> | undefined) {
147
- if (owner) managedBrowserScope(owner).launchPromise = launchPromise;
148
- else state.launchPromise = launchPromise;
152
+ const scope = owner ? managedBrowserScope(owner) : state;
153
+ scope.launchPromise = launchPromise;
154
+ // Retain one settled result so attach probes can observe a launch after cleanup.
155
+ if (launchPromise) scope.latestLaunchPromise = launchPromise;
149
156
  }
150
157
 
151
158
  function managedBrowserPort(owner?: object) {
@@ -186,14 +193,6 @@ function formatError(error: unknown) {
186
193
  return error instanceof Error ? error.message : String(error);
187
194
  }
188
195
 
189
- function isNodeError(error: unknown): error is NodeJS.ErrnoException {
190
- return error instanceof Error && "code" in error;
191
- }
192
-
193
- function normalizePathForComparison(value: string) {
194
- return process.platform === "win32" ? value.toLowerCase() : value;
195
- }
196
-
197
196
  export async function ensureDevToolsEndpoint(
198
197
  waitMs = DEFAULT_ENDPOINT_WAIT_MS,
199
198
  callerSignal?: AbortSignal,
@@ -205,14 +204,19 @@ export async function ensureDevToolsEndpoint(
205
204
  await ensureManagedBrowserLaunched(waitMs, callerSignal, owner);
206
205
  return;
207
206
  }
207
+ const pendingLaunch = launchPromiseForOwner(owner);
208
+ if (pendingLaunch) {
209
+ await waitForCaller(pendingLaunch, callerSignal);
210
+ return;
211
+ }
208
212
  if (canAutoLaunchBrowser(owner)) {
213
+ const launchBeforeProbe = latestLaunchPromiseForOwner(owner);
209
214
  try {
210
215
  await withEndpointRetry(
211
216
  () => fetchDevToolsJson<unknown>("/json/version", { signal: callerSignal }, owner),
212
217
  waitMs,
213
218
  callerSignal,
214
219
  );
215
- return;
216
220
  } catch (error) {
217
221
  if (shouldAutoLaunchAfterEndpointError(error, owner)) {
218
222
  await ensureManagedBrowserLaunched(waitMs, callerSignal, owner);
@@ -220,6 +224,16 @@ export async function ensureDevToolsEndpoint(
220
224
  }
221
225
  throw error;
222
226
  }
227
+ // A retry can reach a managed port whose launch has already completed cleanup.
228
+ // Observe its result even after the active launch promise has been cleared.
229
+ let observedLaunch = launchBeforeProbe;
230
+ while (true) {
231
+ const concurrentLaunch = latestLaunchPromiseForOwner(owner);
232
+ if (!concurrentLaunch || concurrentLaunch === observedLaunch) break;
233
+ observedLaunch = concurrentLaunch;
234
+ await waitForCaller(concurrentLaunch, callerSignal);
235
+ }
236
+ return;
223
237
  }
224
238
 
225
239
  try {
@@ -303,7 +317,7 @@ async function prepareManagedBrowserLaunch(waitMs: number, generation: number, s
303
317
  throwIfBrowserLaunchCancelled(generation, signal, owner);
304
318
  if (!available) {
305
319
  throw new DevToolsEndpointError(
306
- `Cannot launch the extension-owned browser because explicit port ${managedBrowserPort(owner)} is already in use. Choose a free browser.endpoint port or reset the endpoint to use a dynamic managed port.`,
320
+ `Cannot launch the extension-owned browser because explicit port ${managedBrowserPort(owner)} is already in use. Choose a free browser.endpoint port or reset the endpoint to use an automatically selected managed port.`,
307
321
  );
308
322
  }
309
323
  }
@@ -312,12 +326,14 @@ async function prepareManagedBrowserLaunch(waitMs: number, generation: number, s
312
326
 
313
327
  async function launchManagedBrowser(waitMs: number, generation: number, signal: AbortSignal, owner?: object) {
314
328
  throwIfBrowserLaunchCancelled(generation, signal, owner);
315
- const candidateDefinitions = browserCandidateDefinitions(owner);
316
- const candidates = await resolveBrowserCandidates(candidateDefinitions, generation, signal, owner);
329
+ const candidateDefinitions = browserCandidateDefinitions(browserSettingsForOwner(owner));
330
+ const candidates = await resolveBrowserCandidates(candidateDefinitions, browserManagerOperations.access, () =>
331
+ throwIfBrowserLaunchCancelled(generation, signal, owner),
332
+ );
317
333
  throwIfBrowserLaunchCancelled(generation, signal, owner);
318
334
  setLastLaunchAttempt(owner, {
319
335
  candidateLabels: candidateDefinitions.map(formatBrowserCandidateDefinition),
320
- mode: managedBrowserPortConfigured(owner) ? "explicit-port" : "dynamic-port",
336
+ mode: managedBrowserPortConfigured(owner) ? "explicit-port" : "automatic-port",
321
337
  });
322
338
 
323
339
  if (candidates.length === 0) {
@@ -334,7 +350,7 @@ async function launchManagedBrowser(waitMs: number, generation: number, signal:
334
350
  setLastLaunchAttempt(owner, {
335
351
  ...lastLaunchAttempt(owner),
336
352
  candidateLabels: candidateDefinitions.map(formatBrowserCandidateDefinition),
337
- mode: managedBrowserPortConfigured(owner) ? "explicit-port" : "dynamic-port",
353
+ mode: managedBrowserPortConfigured(owner) ? "explicit-port" : "automatic-port",
338
354
  selectedCandidate: formatBrowserCandidate(candidate),
339
355
  userDataDir: managedBrowserForOwner(owner)?.userDataDir,
340
356
  });
@@ -346,7 +362,7 @@ async function launchManagedBrowser(waitMs: number, generation: number, signal:
346
362
  setLastLaunchAttempt(owner, {
347
363
  ...(lastLaunchAttempt(owner) ?? {
348
364
  candidateLabels: candidateDefinitions.map(formatBrowserCandidateDefinition),
349
- mode: managedBrowserPortConfigured(owner) ? "explicit-port" : "dynamic-port",
365
+ mode: managedBrowserPortConfigured(owner) ? "explicit-port" : "automatic-port",
350
366
  }),
351
367
  lastError: formatError(cancellation),
352
368
  });
@@ -356,7 +372,7 @@ async function launchManagedBrowser(waitMs: number, generation: number, signal:
356
372
  setLastLaunchAttempt(owner, {
357
373
  ...(lastLaunchAttempt(owner) ?? {
358
374
  candidateLabels: candidateDefinitions.map(formatBrowserCandidateDefinition),
359
- mode: managedBrowserPortConfigured(owner) ? "explicit-port" : "dynamic-port",
375
+ mode: managedBrowserPortConfigured(owner) ? "explicit-port" : "automatic-port",
360
376
  }),
361
377
  lastError: formatError(error),
362
378
  });
@@ -425,15 +441,22 @@ async function launchBrowserCandidate(
425
441
  let managedBrowser: ManagedBrowser | undefined;
426
442
  try {
427
443
  throwIfBrowserLaunchCancelled(generation, signal, owner);
428
- const portArgument = managedBrowserPortConfigured(owner) ? String(managedBrowserPort(owner)) : "0";
429
- const args = buildManagedBrowserLaunchArguments(userDataDir, portArgument, managedBrowserExtensionPaths(owner));
444
+ const port = managedBrowserPortConfigured(owner)
445
+ ? managedBrowserPort(owner)
446
+ : await browserManagerOperations.allocatePort(browserSettingsForOwner(owner).host);
447
+ throwIfBrowserLaunchCancelled(generation, signal, owner);
448
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
449
+ throw new DevToolsEndpointError("Cannot launch a browser without a valid nonzero DevTools port.");
450
+ }
451
+ const args = buildManagedBrowserLaunchArguments(userDataDir, String(port), managedBrowserExtensionPaths(owner));
430
452
  const child = browserManagerOperations.spawn(candidate.resolvedExecutable, args, {
431
453
  shell: false,
432
- stdio: "ignore",
454
+ stdio: ["ignore", "ignore", "pipe"],
433
455
  });
434
456
  const launchedBrowser: ManagedBrowser = {
435
457
  process: child,
436
458
  userDataDir,
459
+ port,
437
460
  exited: false,
438
461
  ready: false,
439
462
  ownerGeneration: generation,
@@ -453,24 +476,12 @@ async function launchBrowserCandidate(
453
476
  }
454
477
  });
455
478
 
456
- await waitForBrowserSpawn(child);
479
+ const browserIdentity = await waitForBrowserStartup(child, port, waitMs, signal);
457
480
  throwIfBrowserLaunchCancelled(generation, signal, owner);
458
- if (managedBrowserPortConfigured(owner)) {
459
- launchedBrowser.port = managedBrowserPort(owner);
460
- } else {
461
- launchedBrowser.port = await readManagedBrowserPort(
462
- userDataDir,
463
- launchedBrowser,
464
- waitMs,
465
- generation,
466
- signal,
467
- owner,
468
- );
469
- throwIfBrowserLaunchCancelled(generation, signal, owner);
470
- setManagedBrowserPort(owner, launchedBrowser.port);
471
- }
472
- await waitForDevToolsEndpoint(waitMs, launchedBrowser, generation, signal, owner);
481
+ setManagedBrowserPort(owner, port);
482
+ await waitForDevToolsEndpoint(waitMs, launchedBrowser, browserIdentity, generation, signal, owner);
473
483
  throwIfBrowserLaunchCancelled(generation, signal, owner);
484
+ throwIfManagedBrowserExited(launchedBrowser);
474
485
  launchedBrowser.ready = true;
475
486
  } catch (error) {
476
487
  if (managedBrowser) await shutdownManagedBrowser(managedBrowser, { awaitLaunch: false, owner });
@@ -503,61 +514,10 @@ export function buildManagedBrowserLaunchArguments(
503
514
  ];
504
515
  }
505
516
 
506
- function waitForBrowserSpawn(child: ChildProcess) {
507
- return new Promise<void>((resolve, reject) => {
508
- let settled = false;
509
- const settle = (callback: () => void) => {
510
- if (settled) return;
511
- settled = true;
512
- child.off("error", onError);
513
- child.off("spawn", onSpawn);
514
- callback();
515
- };
516
- const onError = (error: Error) => settle(() => reject(error));
517
- const onSpawn = () => settle(resolve);
518
- child.once("error", onError);
519
- child.once("spawn", onSpawn);
520
- });
521
- }
522
-
523
- async function readManagedBrowserPort(
524
- userDataDir: string,
525
- managedBrowser: ManagedBrowser,
526
- waitMs: number,
527
- generation: number,
528
- signal: AbortSignal,
529
- owner?: object,
530
- ) {
531
- const activePortFile = join(userDataDir, DEVTOOLS_ACTIVE_PORT_FILE);
532
- const deadline = Date.now() + waitMs;
533
- while (true) {
534
- throwIfManagedBrowserExited(managedBrowser);
535
- const text = await browserManagerOperations.readFile(activePortFile, "utf8").catch((error: unknown) => {
536
- if (isNodeError(error) && error.code === "ENOENT") return undefined;
537
- throw error;
538
- });
539
- throwIfBrowserLaunchCancelled(generation, signal, owner);
540
- const portText = text?.split(/\r?\n/, 1)[0]?.trim();
541
- const port = Number(portText);
542
- if (Number.isInteger(port) && port > 0) return port;
543
-
544
- const remainingMs = deadline - Date.now();
545
- if (remainingMs <= 0) {
546
- throw new DevToolsEndpointError(
547
- [
548
- "Timed out waiting for auto-launched browser DevToolsActivePort.",
549
- `Expected file: ${activePortFile}`,
550
- launchHint(owner),
551
- ].join("\n"),
552
- );
553
- }
554
- await browserManagerOperations.sleep(Math.min(DEFAULT_ENDPOINT_RETRY_MS, remainingMs), signal);
555
- }
556
- }
557
-
558
517
  async function waitForDevToolsEndpoint(
559
518
  waitMs: number,
560
519
  managedBrowser: ManagedBrowser,
520
+ browserIdentity: string,
561
521
  generation: number,
562
522
  signal: AbortSignal,
563
523
  owner?: object,
@@ -566,8 +526,16 @@ async function waitForDevToolsEndpoint(
566
526
  while (true) {
567
527
  throwIfManagedBrowserExited(managedBrowser);
568
528
  try {
569
- await fetchDevToolsJson<unknown>("/json/version", { signal }, owner);
529
+ const version = await fetchDevToolsJson<{ webSocketDebuggerUrl?: unknown } | null>(
530
+ "/json/version",
531
+ { signal },
532
+ owner,
533
+ );
570
534
  throwIfBrowserLaunchCancelled(generation, signal, owner);
535
+ throwIfManagedBrowserExited(managedBrowser);
536
+ if (browserWebSocketIdentity(version?.webSocketDebuggerUrl, managedBrowser.port ?? 0) !== browserIdentity) {
537
+ throw new DevToolsEndpointError("DevTools endpoint does not match the auto-launched browser.");
538
+ }
571
539
  return;
572
540
  } catch (error) {
573
541
  if (!isRetryableEndpointError(error)) throw error;
@@ -749,7 +717,7 @@ function shouldAutoLaunchAfterEndpointError(error: unknown, owner?: object) {
749
717
  if (isLaunchableEndpointError(error)) return true;
750
718
 
751
719
  // After the attach-first attempt (including its retry window, when applicable) fails, treat any
752
- // DevTools endpoint error on an unpinned port as a conflict we can avoid with a dynamic port.
720
+ // DevTools endpoint error on an unpinned port as a conflict we can avoid with an automatically selected port.
753
721
  return !managedBrowserPortConfigured(owner) && error instanceof DevToolsEndpointError;
754
722
  }
755
723
 
@@ -807,20 +775,22 @@ export function launchModeLabel(owner?: object) {
807
775
  if (managedBrowser && !managedBrowser.exited) {
808
776
  return managedBrowserPortConfigured(owner)
809
777
  ? "extension-owned browser on explicit port"
810
- : "extension-owned browser on dynamic port";
778
+ : "extension-owned browser on automatically selected port";
811
779
  }
812
780
  return managedBrowserPortConfigured(owner)
813
781
  ? "force extension-owned launch on explicit port"
814
- : "force extension-owned launch on dynamic port";
782
+ : "force extension-owned launch on automatically selected port";
815
783
  }
816
784
  if (!isLocalDevToolsHost(browser.host)) return "manual remote endpoint";
817
785
  if (!browser.autoLaunchEnabled) return "manual; auto-launch disabled";
818
786
  if (managedBrowser && !managedBrowser.exited) {
819
- return managedBrowserPortConfigured(owner) ? "auto-launched on explicit port" : "auto-launched on dynamic port";
787
+ return managedBrowserPortConfigured(owner)
788
+ ? "auto-launched on explicit port"
789
+ : "auto-launched on automatically selected port";
820
790
  }
821
791
  return managedBrowserPortConfigured(owner)
822
792
  ? "attach first; auto-launch explicit port"
823
- : "attach first; auto-launch dynamic port";
793
+ : "attach first; auto-launch on an automatically selected port";
824
794
  }
825
795
 
826
796
  export function launchAttemptLines(owner?: object) {
@@ -851,7 +821,7 @@ export function launchHint(owner?: object) {
851
821
  }
852
822
  const managedMode = managedBrowserPortConfigured(owner)
853
823
  ? `port ${managedBrowserPort(owner)}`
854
- : "a dynamic DevTools port";
824
+ : "an automatically selected nonzero DevTools port";
855
825
  return `If no endpoint is available, Pi will auto-launch a Chromium-family browser with ${managedMode} and an isolated temp profile. Manual command: ${chromeLaunchCommand(owner)}`;
856
826
  }
857
827
 
@@ -859,7 +829,7 @@ export function browserCandidateHint(owner?: object) {
859
829
  if (extensionsConfigured(owner)) {
860
830
  return "Extension browser requirement: configure Chrome for Testing or Chromium with browser.executablePath; branded Chrome is rejected because it may ignore unpacked-extension flags.";
861
831
  }
862
- return `Browser candidates: ${browserCandidateDefinitions(owner)
832
+ return `Browser candidates: ${browserCandidateDefinitions(browserSettingsForOwner(owner))
863
833
  .map((candidate) => candidate.label)
864
834
  .join(", ")}`;
865
835
  }
@@ -891,173 +861,6 @@ export function isLocalDevToolsHost(host: string) {
891
861
  return ["localhost", "127.0.0.1", "::1", "0:0:0:0:0:0:0:1"].includes(normalizedHost);
892
862
  }
893
863
 
894
- function browserCandidateDefinitions(owner?: object): BrowserCandidateDefinition[] {
895
- const explicitCandidate = explicitBrowserCandidateDefinition(owner);
896
- if (explicitCandidate.length > 0) return explicitCandidate;
897
-
898
- return uniqueBrowserCandidates(platformBrowserCandidateDefinitions());
899
- }
900
-
901
- function explicitBrowserCandidateDefinition(owner?: object): BrowserCandidateDefinition[] {
902
- const browser = browserSettingsForOwner(owner);
903
- if (!browser.executablePath) return [];
904
- return [
905
- {
906
- label: browser.executablePathSource === "environment" ? "PI_CHROME_DEVTOOLS_BROWSER" : "browser.executablePath",
907
- executable: browser.executablePath,
908
- source: "env",
909
- },
910
- ];
911
- }
912
-
913
- function platformBrowserCandidateDefinitions(): BrowserCandidateDefinition[] {
914
- if (process.platform === "darwin") {
915
- return [
916
- {
917
- label: "Google Chrome",
918
- executable: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
919
- source: "wellKnownPath",
920
- },
921
- {
922
- label: "Chromium",
923
- executable: "/Applications/Chromium.app/Contents/MacOS/Chromium",
924
- source: "wellKnownPath",
925
- },
926
- {
927
- label: "Brave Browser",
928
- executable: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
929
- source: "wellKnownPath",
930
- },
931
- {
932
- label: "Microsoft Edge",
933
- executable: "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
934
- source: "wellKnownPath",
935
- },
936
- ];
937
- }
938
-
939
- if (process.platform === "win32") {
940
- return windowsBrowserCandidateDefinitions();
941
- }
942
-
943
- return [
944
- { label: "Google Chrome", executable: "google-chrome", source: "path" },
945
- { label: "Google Chrome Stable", executable: "google-chrome-stable", source: "path" },
946
- { label: "Chromium", executable: "chromium", source: "path" },
947
- { label: "Chromium Browser", executable: "chromium-browser", source: "path" },
948
- { label: "Brave Browser", executable: "brave-browser", source: "path" },
949
- { label: "Brave", executable: "brave", source: "path" },
950
- { label: "Microsoft Edge", executable: "microsoft-edge", source: "path" },
951
- { label: "Microsoft Edge Stable", executable: "microsoft-edge-stable", source: "path" },
952
- ];
953
- }
954
-
955
- function windowsBrowserCandidateDefinitions(): BrowserCandidateDefinition[] {
956
- const programFiles = [process.env.PROGRAMFILES, process.env["PROGRAMFILES(X86)"], process.env.LOCALAPPDATA].filter(
957
- (value): value is string => typeof value === "string" && value.length > 0,
958
- );
959
- const wellKnownPaths = programFiles.flatMap((root) => [
960
- join(root, "Google", "Chrome", "Application", "chrome.exe"),
961
- join(root, "Chromium", "Application", "chrome.exe"),
962
- join(root, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
963
- join(root, "Microsoft", "Edge", "Application", "msedge.exe"),
964
- ]);
965
- return [
966
- ...wellKnownPaths.map((executable) => ({
967
- label: browserLabelFromExecutable(executable),
968
- executable,
969
- source: "wellKnownPath" as const,
970
- })),
971
- { label: "Google Chrome", executable: "chrome.exe", source: "path" },
972
- { label: "Chromium", executable: "chromium.exe", source: "path" },
973
- { label: "Brave Browser", executable: "brave.exe", source: "path" },
974
- { label: "Microsoft Edge", executable: "msedge.exe", source: "path" },
975
- ];
976
- }
977
-
978
- function browserLabelFromExecutable(executable: string) {
979
- const normalizedExecutable = normalizePathForComparison(executable);
980
- if (normalizedExecutable.includes("brave")) return "Brave Browser";
981
- if (normalizedExecutable.includes("edge") || normalizedExecutable.includes("msedge")) {
982
- return "Microsoft Edge";
983
- }
984
- if (normalizedExecutable.includes("chromium")) return "Chromium";
985
- return "Google Chrome";
986
- }
987
-
988
- function uniqueBrowserCandidates(candidates: BrowserCandidateDefinition[]) {
989
- const seen = new Set<string>();
990
- return candidates.filter((candidate) => {
991
- const key = normalizePathForComparison(candidate.executable);
992
- if (seen.has(key)) return false;
993
- seen.add(key);
994
- return true;
995
- });
996
- }
997
-
998
- async function resolveBrowserCandidates(
999
- definitions: BrowserCandidateDefinition[],
1000
- generation: number,
1001
- signal: AbortSignal,
1002
- owner?: object,
1003
- ) {
1004
- const candidates: BrowserCandidate[] = [];
1005
- for (const definition of definitions) {
1006
- const resolvedExecutable = await resolveBrowserExecutable(definition.executable);
1007
- throwIfBrowserLaunchCancelled(generation, signal, owner);
1008
- if (!resolvedExecutable) continue;
1009
- candidates.push({ ...definition, resolvedExecutable });
1010
- }
1011
- return uniqueBrowserCandidatesByResolvedPath(candidates);
1012
- }
1013
-
1014
- function uniqueBrowserCandidatesByResolvedPath(candidates: BrowserCandidate[]) {
1015
- const seen = new Set<string>();
1016
- return candidates.filter((candidate) => {
1017
- const key = normalizePathForComparison(resolve(candidate.resolvedExecutable));
1018
- if (seen.has(key)) return false;
1019
- seen.add(key);
1020
- return true;
1021
- });
1022
- }
1023
-
1024
- async function resolveBrowserExecutable(executable: string) {
1025
- if (hasPathSeparator(executable) || isAbsolute(executable)) {
1026
- const resolvedExecutable = isAbsolute(executable) ? executable : resolve(executable);
1027
- return (await canAccessExecutable(resolvedExecutable)) ? resolvedExecutable : undefined;
1028
- }
1029
-
1030
- for (const directory of executableSearchPath()) {
1031
- for (const executableName of executableSearchNames(executable)) {
1032
- const candidate = join(directory, executableName);
1033
- if (await canAccessExecutable(candidate)) return candidate;
1034
- }
1035
- }
1036
- return undefined;
1037
- }
1038
-
1039
- function hasPathSeparator(path: string) {
1040
- return path.includes("/") || path.includes("\\");
1041
- }
1042
-
1043
- function executableSearchPath() {
1044
- return (process.env.PATH ?? "").split(delimiter).filter((part) => part.length > 0);
1045
- }
1046
-
1047
- function executableSearchNames(executable: string) {
1048
- if (process.platform !== "win32" || /\.[a-z0-9]+$/i.test(executable)) return [executable];
1049
- return [executable, `${executable}.exe`, `${executable}.cmd`, `${executable}.bat`];
1050
- }
1051
-
1052
- async function canAccessExecutable(path: string) {
1053
- try {
1054
- await browserManagerOperations.access(path, process.platform === "win32" ? constants.F_OK : constants.X_OK);
1055
- return true;
1056
- } catch {
1057
- return false;
1058
- }
1059
- }
1060
-
1061
864
  function formatBrowserCandidate(candidate: BrowserCandidate) {
1062
865
  return `${candidate.label} (${candidate.resolvedExecutable})`;
1063
866
  }
@@ -1121,19 +924,33 @@ function inspectBrowserVersion(executable: string, signal: AbortSignal) {
1121
924
  });
1122
925
  }
1123
926
 
1124
- function isPortAvailable(host: string, port: number) {
1125
- return new Promise<boolean>((resolveAvailability, reject) => {
927
+ export async function allocateBrowserPort(host: string) {
928
+ const port = await probeBrowserPort(host, 0);
929
+ if (port === undefined) throw new Error("Cannot allocate an available DevTools port.");
930
+ return port;
931
+ }
932
+
933
+ async function isPortAvailable(host: string, port: number) {
934
+ return (await probeBrowserPort(host, port)) !== undefined;
935
+ }
936
+
937
+ // The probe always closes before publication. Chrome's startup identity check handles the
938
+ // unavoidable bind-close-spawn race without attaching to a competing CDP browser.
939
+ function probeBrowserPort(host: string, port: number) {
940
+ return new Promise<number | undefined>((resolvePort, reject) => {
1126
941
  const server = createServer();
1127
942
  const normalizedHost = host.replace(/^\[(.*)]$/, "$1");
1128
943
  server.unref();
1129
944
  server.once("error", (error: NodeJS.ErrnoException) => {
1130
- if (error.code === "EADDRINUSE") resolveAvailability(false);
945
+ if (error.code === "EADDRINUSE") resolvePort(undefined);
1131
946
  else reject(error);
1132
947
  });
1133
948
  server.listen(port, normalizedHost, () => {
949
+ const address = server.address();
1134
950
  server.close((error) => {
1135
951
  if (error) reject(error);
1136
- else resolveAvailability(true);
952
+ else if (!address || typeof address === "string") reject(new Error("DevTools port probe has no address."));
953
+ else resolvePort(address.port);
1137
954
  });
1138
955
  });
1139
956
  });