aboutsummaryrefslogtreecommitdiff
path: root/src/cdp/local-launch.ts
blob: 6e74afda832b536b6bae9dffbd8a6270c651b219 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
// FORK: start a Chrome of our own when the target is an explicit `local` and none is reachable.
//
// Only for an explicit local target (BU_CDP_HTTP=local or /browser-target local), never for the
// castle default: an explicit target is an instruction, and a default must not start browsers.
// Always the dedicated profile, never the user's own: Chrome ignores --remote-debugging-port on its
// default user-data-dir, and launching the signed-in profile unasked would be a surprise.
//
// Runs inside the daemon, so builtins only (see test/daemon/entry-imports-test.ts).

import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { type Result, err, ok } from "../util/result";

export const LOCAL_LAUNCH_PORT = 9222;
const MAC_CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
const OFF = new Set(["0", "off", "no", "false", "none"]);
const READY_DEADLINE_MS = 15_000;
const READY_INTERVAL_MS = 250;

export type LaunchPlan = {
  readonly exe: string;
  readonly args: ReadonlyArray<string>;
  readonly profileDir: string;
  readonly port: number;
};

// "skip" means launching does not apply here (disabled, wrong platform, no Chrome); the caller keeps its own error.
export type LaunchPlanResult = { readonly kind: "plan"; readonly plan: LaunchPlan } | { readonly kind: "skip"; readonly reason: string };

export const planLocalLaunch = (
  env: NodeJS.ProcessEnv = process.env,
  platform: string = process.platform,
  exists: (path: string) => boolean = existsSync,
): LaunchPlanResult => {
  const switchValue = env["BU_LOCAL_CHROME_LAUNCH"]?.trim().toLowerCase();
  if (switchValue !== undefined && OFF.has(switchValue)) {
    return { kind: "skip", reason: "disabled by BU_LOCAL_CHROME_LAUNCH" };
  }
  if (platform !== "darwin") return { kind: "skip", reason: `auto-launch is macOS only (this is ${platform})` };

  const exe = env["BU_LOCAL_CHROME_EXE"]?.trim() || MAC_CHROME;
  if (!exists(exe)) return { kind: "skip", reason: `no Chrome at ${exe}` };

  const profileDir = env["BU_LOCAL_CHROME_PROFILE"]?.trim() || join(homedir(), ".chrome-cdp-profile");
  const port = LOCAL_LAUNCH_PORT;
  return {
    kind: "plan",
    plan: {
      exe,
      profileDir,
      port,
      args: [
        `--remote-debugging-port=${port}`,
        `--user-data-dir=${profileDir}`,
        "--no-first-run",
        "--no-default-browser-check",
        "--hide-crash-restore-bubble",
      ],
    },
  };
};

export type LaunchDeps = {
  readonly start: (exe: string, args: ReadonlyArray<string>) => Promise<string | undefined>;
  readonly probe: (port: number) => Promise<string | null>;
  readonly sleep: (ms: number) => Promise<void>;
  readonly now: () => number;
};

const startDetached = (exe: string, args: ReadonlyArray<string>): Promise<string | undefined> =>
  new Promise((resolve) => {
    const child = spawn(exe, [...args], { detached: true, stdio: "ignore" });
    child.once("spawn", () => {
      child.unref();
      resolve(undefined);
    });
    child.once("error", (e: Error) => resolve(e.message));
  });

export const defaultLaunchDeps = (probe: LaunchDeps["probe"]): LaunchDeps => ({
  start: startDetached,
  probe,
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
  now: () => Date.now(),
});

// Resolves to the new browser's webSocketDebuggerUrl, or says why it never answered.
export const launchLocalChrome = async (plan: LaunchPlan, deps: LaunchDeps): Promise<Result<string, string>> => {
  const failure = await deps.start(plan.exe, plan.args);
  if (failure !== undefined) return err(`could not start ${plan.exe}: ${failure}`);

  const end = deps.now() + READY_DEADLINE_MS;
  while (deps.now() < end) {
    const ws = await deps.probe(plan.port);
    if (ws) return ok(ws);
    await deps.sleep(READY_INTERVAL_MS);
  }
  return err(
    `started Chrome with profile ${plan.profileDir}, but nothing answered on 127.0.0.1:${plan.port} within ${READY_DEADLINE_MS / 1000}s. ` +
      `If that profile is already open in a Chrome started without --remote-debugging-port, quit it and retry.`,
  );
};