import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import fs from "node:fs"; import net from "node:net"; import os from "node:os"; import path from "node:path"; import process from "node:process"; export type PlatformCandidates = { darwin?: string[]; win32?: string[]; default: string[]; }; type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void; timer: ReturnType | null; }; type CdpSendOptions = { sessionId?: string; timeoutMs?: number; }; type FetchJsonOptions = { timeoutMs?: number; }; type FindChromeExecutableOptions = { candidates: PlatformCandidates; envNames?: string[]; }; type ResolveSharedChromeProfileDirOptions = { envNames?: string[]; appDataDirName?: string; profileDirName?: string; wslWindowsHome?: string | null; }; type FindExistingChromeDebugPortOptions = { profileDir: string; timeoutMs?: number; }; type LaunchChromeOptions = { chromePath: string; profileDir: string; port: number; url?: string; headless?: boolean; extraArgs?: string[]; }; type ChromeTargetInfo = { targetId: string; url: string; type: string; }; type OpenPageSessionOptions = { cdp: CdpConnection; reusing: boolean; url: string; matchTarget: (target: ChromeTargetInfo) => boolean; enablePage?: boolean; enableRuntime?: boolean; enableDom?: boolean; enableNetwork?: boolean; activateTarget?: boolean; }; export type PageSession = { sessionId: string; targetId: string; }; export function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } export async function getFreePort(fixedEnvName?: string): Promise { const fixed = fixedEnvName ? Number.parseInt(process.env[fixedEnvName] ?? "", 10) : NaN; if (Number.isInteger(fixed) && fixed > 0) return fixed; return await new Promise((resolve, reject) => { const server = net.createServer(); server.unref(); server.on("error", reject); server.listen(0, "127.0.0.1", () => { const address = server.address(); if (!address || typeof address === "string") { server.close(() => reject(new Error("Unable to allocate a free TCP port."))); return; } const port = address.port; server.close((err) => { if (err) reject(err); else resolve(port); }); }); }); } export function findChromeExecutable(options: FindChromeExecutableOptions): string | undefined { for (const envName of options.envNames ?? []) { const override = process.env[envName]?.trim(); if (override && fs.existsSync(override)) return override; } const candidates = process.platform === "darwin" ? options.candidates.darwin ?? options.candidates.default : process.platform === "win32" ? options.candidates.win32 ?? options.candidates.default : options.candidates.default; for (const candidate of candidates) { if (fs.existsSync(candidate)) return candidate; } return undefined; } export function resolveSharedChromeProfileDir(options: ResolveSharedChromeProfileDirOptions = {}): string { for (const envName of options.envNames ?? []) { const override = process.env[envName]?.trim(); if (override) return path.resolve(override); } const appDataDirName = options.appDataDirName ?? "baoyu-skills"; const profileDirName = options.profileDirName ?? "chrome-profile"; if (options.wslWindowsHome) { return path.join(options.wslWindowsHome, ".local", "share", appDataDirName, profileDirName); } const base = process.platform === "darwin" ? path.join(os.homedir(), "Library", "Application Support") : process.platform === "win32" ? (process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming")) : (process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share")); return path.join(base, appDataDirName, profileDirName); } async function fetchWithTimeout(url: string, timeoutMs?: number): Promise { if (!timeoutMs || timeoutMs <= 0) return await fetch(url, { redirect: "follow" }); const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), timeoutMs); try { return await fetch(url, { redirect: "follow", signal: ctl.signal }); } finally { clearTimeout(timer); } } async function fetchJson(url: string, options: FetchJsonOptions = {}): Promise { const response = await fetchWithTimeout(url, options.timeoutMs); if (!response.ok) { throw new Error(`Request failed: ${response.status} ${response.statusText}`); } return await response.json() as T; } async function isDebugPortReady(port: number, timeoutMs = 3_000): Promise { try { const version = await fetchJson<{ webSocketDebuggerUrl?: string }>( `http://127.0.0.1:${port}/json/version`, { timeoutMs } ); return !!version.webSocketDebuggerUrl; } catch { return false; } } export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise { const timeoutMs = options.timeoutMs ?? 3_000; const portFile = path.join(options.profileDir, "DevToolsActivePort"); try { const content = fs.readFileSync(portFile, "utf-8"); const [portLine] = content.split(/\r?\n/); const port = Number.parseInt(portLine?.trim() ?? "", 10); if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port; } catch {} if (process.platform === "win32") return null; try { const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5_000 }); if (result.status !== 0 || !result.stdout) return null; const lines = result.stdout .split("\n") .filter((line) => line.includes(options.profileDir) && line.includes("--remote-debugging-port=")); for (const line of lines) { const portMatch = line.match(/--remote-debugging-port=(\d+)/); const port = Number.parseInt(portMatch?.[1] ?? "", 10); if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port; } } catch {} return null; } export async function waitForChromeDebugPort( port: number, timeoutMs: number, options?: { includeLastError?: boolean } ): Promise { const start = Date.now(); let lastError: unknown = null; while (Date.now() - start < timeoutMs) { try { const version = await fetchJson<{ webSocketDebuggerUrl?: string }>( `http://127.0.0.1:${port}/json/version`, { timeoutMs: 5_000 } ); if (version.webSocketDebuggerUrl) return version.webSocketDebuggerUrl; lastError = new Error("Missing webSocketDebuggerUrl"); } catch (error) { lastError = error; } await sleep(200); } if (options?.includeLastError && lastError) { throw new Error( `Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}` ); } throw new Error("Chrome debug port not ready"); } export class CdpConnection { private ws: WebSocket; private nextId = 0; private pending = new Map(); private eventHandlers = new Map void>>(); private defaultTimeoutMs: number; private constructor(ws: WebSocket, defaultTimeoutMs = 15_000) { this.ws = ws; this.defaultTimeoutMs = defaultTimeoutMs; this.ws.addEventListener("message", (event) => { try { const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data as ArrayBuffer); const msg = JSON.parse(data) as { id?: number; method?: string; params?: unknown; result?: unknown; error?: { message?: string }; }; if (msg.method) { const handlers = this.eventHandlers.get(msg.method); if (handlers) { handlers.forEach((handler) => handler(msg.params)); } } if (msg.id) { const pending = this.pending.get(msg.id); if (pending) { this.pending.delete(msg.id); if (pending.timer) clearTimeout(pending.timer); if (msg.error?.message) pending.reject(new Error(msg.error.message)); else pending.resolve(msg.result); } } } catch {} }); this.ws.addEventListener("close", () => { for (const [id, pending] of this.pending.entries()) { this.pending.delete(id); if (pending.timer) clearTimeout(pending.timer); pending.reject(new Error("CDP connection closed.")); } }); } static async connect( url: string, timeoutMs: number, options?: { defaultTimeoutMs?: number } ): Promise { const ws = new WebSocket(url); await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error("CDP connection timeout.")), timeoutMs); ws.addEventListener("open", () => { clearTimeout(timer); resolve(); }); ws.addEventListener("error", () => { clearTimeout(timer); reject(new Error("CDP connection failed.")); }); }); return new CdpConnection(ws, options?.defaultTimeoutMs ?? 15_000); } on(method: string, handler: (params: unknown) => void): void { if (!this.eventHandlers.has(method)) { this.eventHandlers.set(method, new Set()); } this.eventHandlers.get(method)?.add(handler); } off(method: string, handler: (params: unknown) => void): void { this.eventHandlers.get(method)?.delete(handler); } async send(method: string, params?: Record, options?: CdpSendOptions): Promise { const id = ++this.nextId; const message: Record = { id, method }; if (params) message.params = params; if (options?.sessionId) message.sessionId = options.sessionId; const timeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs; const result = await new Promise((resolve, reject) => { const timer = timeoutMs > 0 ? setTimeout(() => { this.pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, timeoutMs) : null; this.pending.set(id, { resolve, reject, timer }); this.ws.send(JSON.stringify(message)); }); return result as T; } close(): void { try { this.ws.close(); } catch {} } } export async function launchChrome(options: LaunchChromeOptions): Promise { await fs.promises.mkdir(options.profileDir, { recursive: true }); const args = [ `--remote-debugging-port=${options.port}`, `--user-data-dir=${options.profileDir}`, "--no-first-run", "--no-default-browser-check", ...(options.extraArgs ?? []), ]; if (options.headless) args.push("--headless=new"); if (options.url) args.push(options.url); return spawn(options.chromePath, args, { stdio: "ignore" }); } export function killChrome(chrome: ChildProcess): void { try { chrome.kill("SIGTERM"); } catch {} setTimeout(() => { if (!chrome.killed) { try { chrome.kill("SIGKILL"); } catch {} } }, 2_000).unref?.(); } export async function openPageSession(options: OpenPageSessionOptions): Promise { let targetId: string; if (options.reusing) { const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url }); targetId = created.targetId; } else { const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets"); const existing = targets.targetInfos.find(options.matchTarget); if (existing) { targetId = existing.targetId; } else { const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url }); targetId = created.targetId; } } const { sessionId } = await options.cdp.send<{ sessionId: string }>( "Target.attachToTarget", { targetId, flatten: true } ); if (options.activateTarget ?? true) { await options.cdp.send("Target.activateTarget", { targetId }); } if (options.enablePage) await options.cdp.send("Page.enable", {}, { sessionId }); if (options.enableRuntime) await options.cdp.send("Runtime.enable", {}, { sessionId }); if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId }); if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId }); return { sessionId, targetId }; }