UNPKG

browse

Version:

Unified Browserbase CLI for browser automation and cloud APIs.

323 lines (322 loc) 11.5 kB
import { Stagehand } from "@browserbasehq/stagehand"; import { emptyRefMaps, resolveSelector as resolveCachedSelector, } from "./commands/selectors.js"; import { executeDriverCommand } from "./commands/registry.js"; import { DriverError } from "./errors.js"; import { discoverLocalCdp } from "./local-cdp-discovery.js"; import { NetworkCapture } from "./network-capture.js"; import { getRemote } from "./remote-binding.js"; const INIT_FAILURE_RETRY_MS = 5_000; const INIT_FAILURE_RETRY_MAX_MS = 60_000; // chrome-launcher reports "no Chrome on this machine" with these codes (its // LaunchErrorCodes const enum, which can't be imported directly: const enums // are erased at build time and isolatedModules forbids cross-module access). const CHROME_NOT_FOUND_ERROR_CODES = new Set([ "ERR_LAUNCHER_NOT_INSTALLED", "ERR_LAUNCHER_PATH_NOT_SET", ]); /** * Exponential backoff for cached init failures: 5s, 10s, 20s, ... capped at * 1 minute. Prevents agents stuck in retry loops from hammering init while * still allowing a quick retry after the first failure. */ export function initFailureBackoffMs(consecutiveFailures) { const attempt = Math.max(1, consecutiveFailures); return Math.min(INIT_FAILURE_RETRY_MS * 2 ** (attempt - 1), INIT_FAILURE_RETRY_MAX_MS); } export function isChromeNotFoundError(error) { const code = error?.code; if (typeof code === "string" && CHROME_NOT_FOUND_ERROR_CODES.has(code)) { return true; } return (error instanceof Error && error.message.includes("No Chrome installations found")); } export class DriverSessionManager { session; target; network; consecutiveInitFailures = 0; context = null; initFailure = null; initPromise = null; refMaps = emptyRefMaps(); selectedTargetId; stagehand = null; constructor(session, target) { this.session = session; this.target = target; this.network = new NetworkCapture(session); } async open(url) { return (await this.execute("open", { url })); } async execute(command, params) { return executeDriverCommand(this, command, params); } async activePage() { return this.ensurePage(); } async pageForOpen() { return this.ensurePage({ createIfMissing: true }); } async browserContext() { await this.ensureInitialized(); if (!this.context) { throw new Error("Driver context failed to initialize."); } return this.context; } async stagehandInstance() { await this.ensureInitialized(); if (!this.stagehand) { throw new Error("Stagehand instance failed to initialize."); } return this.stagehand; } async status() { if (!this.stagehand || !this.context) { return { browserConnected: false, initialized: false, mode: this.target.kind, pages: [], pid: process.pid, selectedTargetId: this.selectedTargetId, session: this.session, target: this.target, }; } const page = this.activePageIfPresent(); const pages = await this.pageSummaries(); return { browserConnected: true, initialized: true, mode: this.target.kind, pages, pid: process.pid, selectedTargetId: page?.targetId() ?? this.selectedTargetId, session: this.session, target: this.target, title: page ? await safeTitle(page) : undefined, url: page?.url(), }; } async close() { const stagehand = this.stagehand; this.stagehand = null; this.context = null; this.initFailure = null; this.consecutiveInitFailures = 0; await this.network.disable().catch(() => undefined); if (stagehand) { await stagehand.close().catch(() => undefined); } } resolveSelector(selector) { return resolveCachedSelector(selector, this.refMaps); } setRefMaps(refMaps) { this.refMaps = refMaps; } getRefMaps() { return this.refMaps; } async openResult(page) { return { mode: this.target.kind, pages: await this.pageSummaries(), selectedTargetId: page.targetId(), session: this.session, title: await this.safeTitle(page), url: page.url(), }; } async pageSummaries() { const pages = this.context?.pages() ?? []; return Promise.all(pages.map(async (page, index) => ({ index, targetId: page.targetId(), title: await this.safeTitle(page), url: page.url(), }))); } async safeTitle(page) { return safeTitle(page); } async ensurePage(options = {}) { await this.ensureInitialized(); if (!this.context) { throw new Error("Driver context failed to initialize."); } const target = this.target; if (target.kind === "cdp" && target.targetId) { const page = this.context .pages() .find((candidate) => candidate.targetId() === target.targetId); if (!page) { throw new Error(`Target ${target.targetId} was not found in the attached browser.`); } this.activateIfNeeded(page); this.selectedTargetId = page.targetId(); return page; } const existingPage = this.activePageIfPresent() ?? this.context.pages()[0]; if (existingPage) { this.activateIfNeeded(existingPage); this.selectedTargetId = existingPage.targetId(); return existingPage; } if (options.createIfMissing) { const page = await this.context.newPage(); this.activateIfNeeded(page); this.selectedTargetId = page.targetId(); return page; } throw new DriverError(`No active page in session "${this.session}". Run browse open <url> --session ${this.session} or browse tab new <url> --session ${this.session}.`, { code: "no_active_page" }); } activePageIfPresent() { try { return this.context?.activePage() ?? undefined; } catch { return undefined; } } /** * Mark `page` active only when it isn't already the active page. * * `setActivePage` ends in a CDP `Target.activateTarget`, which on macOS * raises the whole Chrome app to the OS foreground and steals keyboard focus. * The daemon resolves the active page on every subcommand, so calling this * unconditionally yanks focus away from the user's editor/terminal on each * command in headed local mode. Skipping the redundant re-activation keeps a * headed session usable alongside a coding agent. */ activateIfNeeded(page) { if (page !== this.activePageIfPresent()) { this.context?.setActivePage(page); } } async ensureInitialized() { if (this.stagehand && this.context) return; if (this.initPromise) return this.initPromise; if (this.initFailure) { if (Date.now() < this.initFailure.retryAt) { throw this.initFailure.error; } this.initFailure = null; } this.initPromise = this.initialize() .then(() => { this.initFailure = null; this.consecutiveInitFailures = 0; }) .catch(async (error) => { this.consecutiveInitFailures += 1; const failure = await this.markRepeatedInitFailure(error); this.initFailure = { error: failure, retryAt: Date.now() + initFailureBackoffMs(this.consecutiveInitFailures), }; throw failure; }) .finally(() => { this.initPromise = null; }); return this.initPromise; } async markRepeatedInitFailure(error) { if (this.consecutiveInitFailures < 3 || !(error instanceof Error)) { return error; } const hint = (await getRemote()).driverInitHints().repeatedInitFailure; if (!error.message.includes(hint)) { error.message += hint; } return error; } async initialize() { const resolvedTarget = await this.resolveTarget(); const options = await this.stagehandOptions(resolvedTarget); const stagehand = new Stagehand(options); try { await stagehand.init(); } catch (error) { await stagehand.close().catch(() => undefined); throw await describeInitError(error, resolvedTarget); } this.stagehand = stagehand; this.context = stagehand.context; } async resolveTarget() { if (this.target.kind !== "auto-connect") return this.target; const discovered = await discoverLocalCdp(); if (!discovered) { throw new Error("No debuggable local browser found. Start Chrome with --remote-debugging-port=9222 or pass --cdp <url|port>."); } return { kind: "cdp", endpoint: discovered.wsUrl }; } async stagehandOptions(target) { if (target.kind === "remote") { return (await getRemote()).remoteStagehandOptions(); } if (target.kind === "managed-local") { return { disablePino: true, env: "LOCAL", localBrowserLaunchOptions: { ...(target.chromeArgs?.length ? { args: target.chromeArgs } : {}), ...(target.ignoreDefaultArgs !== undefined ? { ignoreDefaultArgs: target.ignoreDefaultArgs } : {}), headless: target.headless, }, verbose: 0, }; } if (target.kind === "cdp") { return { disablePino: true, env: "LOCAL", localBrowserLaunchOptions: { cdpUrl: target.endpoint, }, verbose: 0, }; } throw new Error(`Unsupported target kind: ${target.kind}`); } } async function safeTitle(page) { try { return await page.title(); } catch { return ""; } } /** * Turn raw `stagehand.init()` failures into typed, actionable errors. Remote * failures are classified by the remote capability (401/403/etc.); a missing * local Chrome gets install/escape-hatch guidance. Anything else is rethrown * unchanged. */ async function describeInitError(error, target) { if (error instanceof DriverError) return error; if (target.kind === "remote") { const { code, httpStatus, message } = (await getRemote()).classifyRemoteInitError(error); return new DriverError(message, { cause: error, code, httpStatus }); } if (target.kind === "managed-local" && isChromeNotFoundError(error)) { return new DriverError((await getRemote()).driverInitHints().chromeNotFound, { cause: error, code: "no_chrome_found", }); } return error; }