aboutsummaryrefslogtreecommitdiff
path: root/src/cdp/local-launch.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/cdp/local-launch.ts')
-rw-r--r--src/cdp/local-launch.ts104
1 files changed, 104 insertions, 0 deletions
diff --git a/src/cdp/local-launch.ts b/src/cdp/local-launch.ts
new file mode 100644
index 0000000..6e74afd
--- /dev/null
+++ b/src/cdp/local-launch.ts
@@ -0,0 +1,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.`,
+ );
+};