aboutsummaryrefslogtreecommitdiff
path: root/src/cdp
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-10-17 13:20:04 +0300
committerIgor Soarez <igor@soarez.org>2026-10-17 13:20:04 +0300
commitf61c43418d704cccb67132540ed7feceacef1d2a (patch)
tree8741f1683b2bffc9e2437beede9cc1fbbf4a3452 /src/cdp
parent99d5702cb2ff0ead8e3a2b7a95fd057eefa89862 (diff)
Start a local Chrome when the target is an explicit local and none answers
Dedicated profile (~/.chrome-cdp-profile), macOS only, never for the castle default. BU_LOCAL_CHROME_LAUNCH=off disables it.
Diffstat (limited to 'src/cdp')
-rw-r--r--src/cdp/discovery.ts13
-rw-r--r--src/cdp/local-launch.ts104
2 files changed, 117 insertions, 0 deletions
diff --git a/src/cdp/discovery.ts b/src/cdp/discovery.ts
index 7aa35f6..ea75c5c 100644
--- a/src/cdp/discovery.ts
+++ b/src/cdp/discovery.ts
@@ -8,6 +8,7 @@ import { type CdpError, cdpError } from "./errors";
import { asString, errnoCode, isRecord } from "../util/guards";
import { describeCdpTarget, resolveCdpTarget } from "./endpoint";
import { readTarget } from "./target-store";
+import { defaultLaunchDeps, launchLocalChrome, planLocalLaunch } from "./local-launch";
const PORT_PROBE_DEADLINE_MS = 30_000;
const PORT_PROBE_INTERVAL_MS = 1_000;
@@ -153,6 +154,18 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>>
// through to the local browser the extension was originally written for.
}
+ const found = await discoverLocalEndpoint();
+ if (found.success || target.kind !== "local") return found;
+
+ // FORK: an explicit local target with no browser to attach to starts one (src/cdp/local-launch.ts).
+ const plan = planLocalLaunch();
+ if (plan.kind === "skip") return found;
+ const launched = await launchLocalChrome(plan.plan, defaultLaunchDeps((port) => queryLiveWsUrl(port)));
+ if (launched.success) return ok({ wsUrl: launched.data });
+ return err(cdpError("discovery_failed", `${found.error.message}\n\nAuto-launch of local Chrome failed: ${launched.error}`));
+};
+
+const discoverLocalEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>> => {
const dirs = userDataDirCandidates();
const candidates: Candidate[] = [];
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.`,
+ );
+};