src/cdp/discovery.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 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 |
import { readFile, stat } from "node:fs/promises";
import { join } from "node:path";
import { connect as netConnect } from "node:net";
import { request as httpRequest } from "node:http";
import { type Result, err, ok } from "../util/result";
import { userDataDirCandidates } from "../profile/paths";
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;
// Hand-rolled on purpose: this file runs inside the daemon, a separate Node process that pi does not
// supply host-provided packages (typebox) to. Everything the daemon imports must be a runtime
// dependency or a builtin; test/daemon/entry-imports-test.ts enforces that.
export const parseVersionResponse = (raw: string): string | null => {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (!isRecord(parsed)) return null;
return asString(parsed["webSocketDebuggerUrl"]) ?? null;
};
export type CdpEndpoint = {
readonly wsUrl: string;
readonly userDataDir?: string;
};
const probePort = (port: number): Promise<Result<void, CdpError>> =>
new Promise((resolve) => {
const sock = netConnect({ host: "127.0.0.1", port });
let settled = false;
const finish = (r: Result<void, CdpError>): void => {
if (settled) return;
settled = true;
sock.setTimeout(0);
sock.destroy();
resolve(r);
};
sock.setTimeout(1000, () => finish(err(cdpError("discovery_failed", "probe timeout"))));
sock.once("error", (e) => finish(err(cdpError("discovery_failed", e.message))));
sock.once("connect", () => finish(ok(undefined)));
});
const waitForPort = async (port: number): Promise<Result<void, CdpError>> => {
const end = Date.now() + PORT_PROBE_DEADLINE_MS;
let lastMessage = "unknown";
while (Date.now() < end) {
const probe = await probePort(port);
if (probe.success) return probe;
lastMessage = probe.error.message;
await new Promise((r) => setTimeout(r, PORT_PROBE_INTERVAL_MS));
}
return err(cdpError(
"discovery_failed",
`Chrome's remote-debugging page is open, but DevTools is not live yet on 127.0.0.1:${port} — if Chrome opened a profile picker, choose your normal profile first, then tick the checkbox and click Allow if shown (last error: ${lastMessage})`,
));
};
const isPortLive = async (port: number): Promise<boolean> => (await probePort(port)).success;
// /json/version is authoritative: a stale DevToolsActivePort file may name a browser that has since exited while another now owns the port.
export const queryLiveWsUrl = (port: number, timeoutMs = 1_500, host = "127.0.0.1"): Promise<string | null> =>
new Promise<string | null>((resolve) => {
const req = httpRequest(
{ host, port, path: "/json/version", method: "GET", timeout: timeoutMs },
(res) => {
if (res.statusCode !== 200) {
res.resume();
resolve(null);
return;
}
const chunks: Buffer[] = [];
res.on("data", (c: Buffer) => chunks.push(c));
res.on("end", () => {
resolve(parseVersionResponse(Buffer.concat(chunks).toString("utf8")));
});
},
);
req.on("timeout", () => { req.destroy(); resolve(null); });
req.on("error", () => resolve(null));
req.end();
});
const fallbackPorts = (): ReadonlyArray<number> => {
const ports = new Set<number>([9222]);
const fromEnv = process.env["BU_CDP_PORTS"];
if (fromEnv) {
for (const tok of fromEnv.split(",")) {
const n = Number(tok.trim());
if (Number.isInteger(n) && n > 0 && n < 65536) ports.add(n);
}
}
return Array.from(ports);
};
type Candidate = {
readonly port: number;
readonly path: string;
readonly mtimeMs: number;
readonly userDataDir: string;
};
// Read here rather than in the client: the daemon owns the only socket to Chrome, so an override the daemon cannot see has no effect.
export const endpointFromEnv = (env: NodeJS.ProcessEnv = process.env): CdpEndpoint | undefined => {
const wsUrl = env["BU_CDP_WS"]?.trim();
return wsUrl ? { wsUrl } : undefined;
};
export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>> => {
const override = endpointFromEnv();
if (override) return ok(override);
// FORK: drive a browser on another host. See src/cdp/endpoint.ts for the
// precedence rules and FORK.md for why this exists at all.
//
// The remote is asked for its canonical webSocketDebuggerUrl on every
// discovery call, so the browser UUID is re-read after the remote browser
// restarts. That is the difference from upstream's BU_CDP_WS (checked just
// above, so an explicit WebSocket URL still wins), which pins a UUID that
// Chrome re-mints on every launch.
//
// No userDataDir is returned: the profile registry lives on the remote host,
// so profile enumeration degrades to the pre-profile behaviour rather than
// reading the wrong machine's Local State.
const target = resolveCdpTarget(process.env, await readTarget());
if (target.kind === "invalid") {
return err(cdpError("discovery_failed", describeCdpTarget(target)));
}
if (target.kind === "remote") {
// FORK: the default is only a preference, and this probe now runs inside the
// bridge's 10s connect budget (upstream starts that timer before discovery), so
// a blackholed castle must not eat the local fallback's time. Explicit targets
// keep the longer wait: they fail hard rather than fall back.
const ws = await queryLiveWsUrl(target.port, target.source === "default" ? 1_500 : 5_000, target.host);
if (ws) return ok({ wsUrl: ws });
// An explicitly configured endpoint is an instruction: fail rather than
// quietly automate a different browser than the one that was asked for.
if (target.source === "env") {
return err(cdpError(
"discovery_failed",
`No CDP endpoint answered at http://${target.host}:${target.port}/json/version (BU_CDP_HTTP). ` +
`Is the remote browser running and reachable? Set BU_CDP_HTTP=local to use this machine's browser instead.`,
));
}
// The built-in default is only a preference — off the wire network, fall
// 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[] = [];
const readErrors: string[] = [];
for (const base of dirs) {
const portFile = join(base, "DevToolsActivePort");
let raw: string;
let mtimeMs = 0;
try {
raw = await readFile(portFile, "utf8");
try {
mtimeMs = (await stat(portFile)).mtimeMs;
} catch {}
} catch (e) {
// EPERM/EACCES is common under sandboxes; remember it and fall back to network probing rather than failing the whole discovery.
const code = errnoCode(e);
if (code === "ENOENT" || code === undefined) continue;
if (code === "EPERM" || code === "EACCES") {
readErrors.push(`${portFile}: ${code}`);
continue;
}
return err(cdpError("discovery_failed", `failed to read ${portFile}: ${e instanceof Error ? e.message : String(e)}`));
}
const lines = raw.trim().split("\n");
if (lines.length < 2) continue;
const port = lines[0]?.trim();
const path = lines[1]?.trim();
if (!port || !path) continue;
// An out-of-range port makes net.connect throw ERR_SOCKET_BAD_PORT synchronously, escaping discoverWsUrl instead of surfacing as a Result error.
const portNum = Number(port);
if (!Number.isInteger(portNum) || portNum <= 0 || portNum >= 65536) continue;
candidates.push({ port: portNum, path, mtimeMs, userDataDir: base });
}
if (candidates.length === 0) {
for (const port of fallbackPorts()) {
if (!(await isPortLive(port))) continue;
const live = await queryLiveWsUrl(port);
if (live) return ok({ wsUrl: live });
}
const permHint = readErrors.length > 0
? `\n\nDevToolsActivePort reads were denied (${readErrors.join(", ")}); if you're running in a sandbox, grant read access to those files or set BU_CDP_WS / BU_CDP_PORTS to bypass file discovery.`
: "";
return err(cdpError(
"discovery_failed",
`DevToolsActivePort not found in ${dirs.join(", ")} — open chrome://inspect/#remote-debugging (or brave://inspect, edge://inspect) in your browser, tick the checkbox, click Allow, then retry. Or set BU_CDP_WS to a remote browser endpoint.${permHint}`,
));
}
const byPort = new Map<number, Candidate>();
for (const c of candidates) {
const prev = byPort.get(c.port);
if (!prev || c.mtimeMs > prev.mtimeMs) byPort.set(c.port, c);
}
const unique = Array.from(byPort.values());
const liveChecks = await Promise.all(unique.map((c) => isPortLive(c.port)));
const liveCandidates = unique.filter((_, i) => liveChecks[i]);
const ordered = liveCandidates.length > 0 ? liveCandidates : unique;
// Some browsers disable the /json/version endpoint when remote debugging is toggled via chrome://inspect rather than a launch flag, hence the DevToolsActivePort fallback.
let lastErr: Result<CdpEndpoint, CdpError> | null = null;
for (const c of ordered) {
const ready = await waitForPort(c.port);
if (!ready.success) {
lastErr = err(ready.error);
continue;
}
const liveUrl = await queryLiveWsUrl(c.port);
return ok({ wsUrl: liveUrl ?? `ws://127.0.0.1:${c.port}${c.path}`, userDataDir: c.userDataDir });
}
for (const port of fallbackPorts()) {
if (byPort.has(port) || !(await isPortLive(port))) continue;
const liveUrl = await queryLiveWsUrl(port);
if (liveUrl) return ok({ wsUrl: liveUrl });
}
return lastErr ?? err(cdpError("discovery_failed", "no live DevTools endpoint among discovered candidates"));
};
export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => {
const endpoint = await discoverEndpoint();
return endpoint.success ? ok(endpoint.data.wsUrl) : endpoint;
};
|