UNPKG

browse

Version:

Unified Browserbase CLI for browser automation and cloud APIs.

274 lines (273 loc) 8.78 kB
import { spawn } from "node:child_process"; import { promises as fs } from "node:fs"; import net from "node:net"; import { fail } from "../../errors.js"; import { targetsCompatible } from "../mode.js"; import { cleanupDaemonFiles, ensureRuntimeDir, getLockPath, getPidPath, getSocketPath, PRIVATE_FILE_MODE, } from "./paths.js"; import { isProcessAlive } from "./process.js"; import { ResponseSchema } from "./protocol.js"; export async function ensureDriverDaemon({ session, target, }) { await ensureRuntimeDir(); const existing = await tryDriverStatus(session); if (existing) { assertCompatibleTarget(session, existing, target); return; } const locked = await acquireLock(session); if (!locked) { fail(`Timed out waiting for driver daemon lock for session "${session}".`); } try { const afterLock = await tryDriverStatus(session); if (afterLock) { assertCompatibleTarget(session, afterLock, target); return; } if (await isDaemonPidAlive(session)) { fail(`Driver daemon session "${session}" is running but not responding. Run browse stop --session ${session} --force to clean it up.`); } spawnDaemon(session, target); await waitForSocketReady(getSocketPath(session), 30_000); } finally { await releaseLock(session); } } export async function openViaDaemon(session, url, options = {}) { return sendDriverRequest(session, { ...options, id: requestId(), type: "open", url, }); } export async function runDriverCommandViaDaemon(session, command, params) { return sendDriverRequest(session, { command, id: requestId(), params, type: "command", }); } export async function getDriverStatus(session) { return tryDriverStatus(session); } export async function stopDriverDaemon(session, force = false) { const status = await tryDriverStatus(session); if (!status) { if (force) { await cleanupDaemonFiles(session); } return { stopped: false }; } try { return await sendDriverRequest(session, { id: requestId(), type: "stop", }); } catch (error) { if (!force) throw error; await cleanupDaemonFiles(session); return { stopped: true }; } } function assertCompatibleTarget(session, status, target) { if (targetsCompatible(status.target, target)) return; fail(`Session "${session}" is already running in ${status.mode} mode. Run browse stop --session ${session} before changing modes.`); } async function tryDriverStatus(session) { if (!(await isSocketConnectable(getSocketPath(session), 500))) { await cleanupStaleDaemonFiles(session); return null; } try { return await sendDriverRequest(session, { id: requestId(), type: "status", }); } catch { await cleanupStaleDaemonFiles(session); return null; } } async function sendDriverRequest(session, request) { const socketPath = getSocketPath(session); return new Promise((resolve, reject) => { const socket = net.createConnection(socketPath); let buffer = ""; let settled = false; const failRequest = (error) => { if (settled) return; settled = true; clearTimeout(timeout); socket.destroy(); reject(error); }; const completeRequest = (value) => { if (settled) return; settled = true; clearTimeout(timeout); socket.end(); resolve(value); }; const incompleteResponseError = () => { const detail = buffer ? "with an incomplete response" : "without a response"; return new Error(`Driver daemon session "${session}" closed ${detail}.`); }; const timeout = setTimeout(() => { failRequest(new Error(`Timed out waiting for driver daemon session "${session}".`)); }, 35_000); socket.on("connect", () => { socket.write(`${JSON.stringify(request)}\n`); }); socket.on("data", (chunk) => { buffer += chunk.toString(); const newline = buffer.indexOf("\n"); if (newline === -1) return; try { const response = ResponseSchema.parse(JSON.parse(buffer.slice(0, newline))); if (response.type === "error") { failRequest(new Error(response.error)); return; } completeRequest(response.data); } catch (error) { failRequest(error instanceof Error ? error : new Error(String(error))); } }); socket.on("error", (error) => { failRequest(error); }); socket.on("end", () => { if (!settled) failRequest(incompleteResponseError()); }); socket.on("close", () => { if (!settled) failRequest(incompleteResponseError()); }); }); } function spawnDaemon(session, target) { const entrypoint = process.argv[1]; if (!entrypoint) { fail("Unable to locate browse CLI entrypoint for daemon startup."); } const child = spawn(process.execPath, [ entrypoint, "daemon", "--session", session, "--target", JSON.stringify(target), ], { detached: true, env: process.env, stdio: "ignore", }); child.unref(); } async function waitForSocketReady(socketPath, timeoutMs) { const start = Date.now(); while (Date.now() - start < timeoutMs) { if (await isSocketConnectable(socketPath, 500)) return; await new Promise((resolve) => setTimeout(resolve, 100)); } throw new Error(`Driver daemon socket was not ready after ${timeoutMs}ms.`); } function isSocketConnectable(socketPath, timeoutMs) { return new Promise((resolve) => { const socket = net.createConnection(socketPath); const timer = setTimeout(() => { socket.destroy(); resolve(false); }, timeoutMs); socket.on("connect", () => { clearTimeout(timer); socket.destroy(); resolve(true); }); socket.on("error", () => { clearTimeout(timer); resolve(false); }); }); } async function cleanupStaleDaemonFiles(session) { if (await isDaemonPidAlive(session)) return; await cleanupDaemonFiles(session, { includeLock: false }); } async function acquireLock(session, timeoutMs = 10_000) { const lockPath = getLockPath(session); const start = Date.now(); while (Date.now() - start < timeoutMs) { try { const handle = await fs.open(lockPath, "wx", PRIVATE_FILE_MODE); try { await handle.write(String(process.pid)); } finally { await handle.close().catch(() => undefined); } return true; } catch (error) { if (error.code !== "EEXIST") throw error; if (await removeStaleLock(lockPath)) continue; await new Promise((resolve) => setTimeout(resolve, 100)); } } return false; } async function releaseLock(session) { await fs.unlink(getLockPath(session)).catch(() => undefined); } async function isDaemonPidAlive(session) { try { const contents = await fs.readFile(getPidPath(session), "utf8"); const pid = Number(contents.trim()); return Number.isInteger(pid) && pid > 0 && isProcessAlive(pid); } catch (error) { if (error.code === "ENOENT") return false; throw error; } } async function removeStaleLock(lockPath) { let ownerPid; try { const contents = await fs.readFile(lockPath, "utf8"); const parsed = Number(contents.trim()); ownerPid = Number.isInteger(parsed) && parsed > 0 ? parsed : null; } catch (error) { if (error.code === "ENOENT") return true; throw error; } if (ownerPid && isProcessAlive(ownerPid)) return false; await fs.unlink(lockPath).catch((error) => { if (error.code !== "ENOENT") throw error; }); return true; } function requestId() { return `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`; }