src/cdp/local-launch.ts (view raw)
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.`,
);
};
|