diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7e130e..98c57b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: - uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 24 cache: pnpm - name: Install dependencies @@ -40,3 +40,30 @@ jobs: - name: Build run: pnpm build + + integration: + name: Integration Tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install Chromium + run: pnpm --filter @office-agents/browser test:install-browsers + + - name: Browser integration tests + run: pnpm --filter @office-agents/browser test:integration + env: + BROWSER_USE_API_KEY: ${{ secrets.BROWSER_USE_API_KEY }} diff --git a/.github/workflows/release-excel.yml b/.github/workflows/release-excel.yml index 2a92ac9..fa8fca4 100644 --- a/.github/workflows/release-excel.yml +++ b/.github/workflows/release-excel.yml @@ -22,7 +22,7 @@ jobs: - uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 24 cache: pnpm - name: Install dependencies diff --git a/.github/workflows/release-ppt.yml b/.github/workflows/release-ppt.yml index c67af38..a243f38 100644 --- a/.github/workflows/release-ppt.yml +++ b/.github/workflows/release-ppt.yml @@ -22,7 +22,7 @@ jobs: - uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 24 cache: pnpm - name: Install dependencies diff --git a/.github/workflows/release-word.yml b/.github/workflows/release-word.yml index f5f52f4..c6fb8d9 100644 --- a/.github/workflows/release-word.yml +++ b/.github/workflows/release-word.yml @@ -22,7 +22,7 @@ jobs: - uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 24 cache: pnpm - name: Install dependencies diff --git a/.gitignore b/.gitignore index 4eaacd8..3df5645 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ npm-debug.log* *.key *.pem headless-test/ + +# Wrangler local state +.wrangler/ diff --git a/package.json b/package.json index 8cab643..71f9fee 100644 --- a/package.json +++ b/package.json @@ -17,13 +17,13 @@ "stop:excel": "pnpm --filter @office-agents/excel stop", "stop:ppt": "pnpm --filter @office-agents/powerpoint stop", "stop:word": "pnpm --filter @office-agents/word stop", - "lint": "biome check packages/sdk/src packages/core/src packages/bridge/src packages/excel/src packages/excel/vite.config.ts packages/powerpoint/src packages/powerpoint/vite.config.ts packages/word/src packages/word/vite.config.ts", - "lint:fix": "biome check --write packages/sdk/src packages/core/src packages/bridge/src packages/excel/src packages/excel/vite.config.ts packages/powerpoint/src packages/powerpoint/vite.config.ts packages/word/src packages/word/vite.config.ts", - "format": "biome format --write packages/sdk/src packages/core/src packages/bridge/src packages/excel/src packages/excel/vite.config.ts packages/powerpoint/src packages/powerpoint/vite.config.ts packages/word/src packages/word/vite.config.ts", + "lint": "biome check packages/sdk/src packages/core/src packages/bridge/src packages/browser/src packages/browser/tests packages/excel/src packages/excel/vite.config.ts packages/powerpoint/src packages/powerpoint/vite.config.ts packages/word/src packages/word/vite.config.ts", + "lint:fix": "biome check --write packages/sdk/src packages/core/src packages/bridge/src packages/browser/src packages/browser/tests packages/excel/src packages/excel/vite.config.ts packages/powerpoint/src packages/powerpoint/vite.config.ts packages/word/src packages/word/vite.config.ts", + "format": "biome format --write packages/sdk/src packages/core/src packages/bridge/src packages/browser/src packages/browser/tests packages/excel/src packages/excel/vite.config.ts packages/powerpoint/src packages/powerpoint/vite.config.ts packages/word/src packages/word/vite.config.ts", "test": "pnpm -r test", - "typecheck": "tsc -b packages/sdk packages/core packages/excel packages/powerpoint packages/word && tsc -p packages/bridge/tsconfig.json --noEmit && pnpm svelte:check", + "typecheck": "tsc -b packages/sdk packages/core packages/excel packages/powerpoint packages/word && tsc -p packages/bridge/tsconfig.json --noEmit && tsc -p packages/browser/tsconfig.json --noEmit && pnpm svelte:check", "svelte:check": "svelte-check --tsconfig ./packages/core/tsconfig.json && svelte-check --tsconfig ./packages/excel/tsconfig.json && svelte-check --tsconfig ./packages/powerpoint/tsconfig.json && svelte-check --tsconfig ./packages/word/tsconfig.json", - "check": "tsc -b packages/sdk packages/core packages/excel packages/powerpoint packages/word && tsc -p packages/bridge/tsconfig.json --noEmit && pnpm svelte:check && biome check packages/sdk/src packages/core/src packages/bridge/src packages/excel/src packages/excel/vite.config.ts packages/powerpoint/src packages/powerpoint/vite.config.ts packages/word/src packages/word/vite.config.ts && pnpm validate", + "check": "tsc -b packages/sdk packages/core packages/excel packages/powerpoint packages/word && tsc -p packages/bridge/tsconfig.json --noEmit && tsc -p packages/browser/tsconfig.json --noEmit && pnpm svelte:check && biome check packages/sdk/src packages/core/src packages/bridge/src packages/browser/src packages/browser/tests packages/excel/src packages/excel/vite.config.ts packages/powerpoint/src packages/powerpoint/vite.config.ts packages/word/src packages/word/vite.config.ts && pnpm validate", "validate": "pnpm --filter @office-agents/excel exec office-addin-manifest validate manifest.xml && pnpm --filter @office-agents/excel exec office-addin-manifest validate manifest.prod.xml && pnpm --filter @office-agents/powerpoint exec office-addin-manifest validate manifest.xml && pnpm --filter @office-agents/powerpoint exec office-addin-manifest validate manifest.prod.xml && pnpm --filter @office-agents/word exec office-addin-manifest validate manifest.xml && pnpm --filter @office-agents/word exec office-addin-manifest validate manifest.prod.xml", "release:excel": "node scripts/release.mjs excel", "release:ppt": "node scripts/release.mjs ppt", diff --git a/packages/browser/PARITY_CHECKLIST.md b/packages/browser/PARITY_CHECKLIST.md new file mode 100644 index 0000000..c7e94ad --- /dev/null +++ b/packages/browser/PARITY_CHECKLIST.md @@ -0,0 +1,138 @@ +# browse ↔ agent-browser parity checklist + +Scope: browser automation and agent-facing CLI ergonomics for `@office-agents/browser`'s `browse` command. + +Out of scope for now: native install/upgrade flows, daemon/dashboard/session registry, iOS/device-management, local DevTools proxying, video/trace/profiler, auth vault, and other features that depend on the Rust daemon architecture from `agent-browser`. + +## Target + +Reach ~90% parity for the browser-automation subset by closing the biggest command-surface gaps and adding command-contract tests. + +## Command parity + +### Navigation / session +- [x] `open` +- [x] `back` +- [x] `forward` +- [x] `reload` +- [x] `status` +- [x] `stop` +- [x] `connect ` +- [x] `close | quit | exit` +- [x] `close --all` (local alias for single in-process session) + +### Core actions +- [x] `click ` +- [x] `click --new-tab` +- [x] `dblclick ` +- [x] `type` (`type ` plus focused-element fallback) +- [x] `fill ` +- [x] `press ` +- [x] `key` alias for `press` +- [x] `keydown ` +- [x] `keyup ` +- [x] `keyboard type ` +- [x] `keyboard inserttext ` +- [x] `hover` +- [x] `focus` +- [x] `check` +- [x] `uncheck` +- [x] `select` +- [x] `drag ` +- [x] `upload ` + +### Scrolling / waiting +- [x] `scroll` (directional mode + low-level XY deltas) +- [x] `scrollintoview | scrollinto` +- [x] `wait ` +- [x] `wait ` +- [x] `wait --text` +- [x] `wait --url` +- [x] `wait --load` +- [x] `wait --fn` +- [x] wait state parity (`visible|hidden|attached|detached`) + +### Artifacts +- [x] `snapshot` +- [ ] `snapshot --selector` +- [x] `screenshot` (selector/path parity) +- [x] `pdf` (base64 + file output parity) +- [x] `download` (URL or selector-driven path parity) + +### Get / state +- [x] `get text` +- [x] `get html` +- [x] `get value` +- [x] `get attr` +- [x] `get title` +- [x] `get url` +- [x] `get count` +- [x] `get box` +- [x] `get styles` +- [x] `get cdp-url` +- [x] `is visible` +- [x] `is enabled` +- [x] `is checked` + +### Semantic locators +- [x] `find role` +- [x] `find text` +- [x] `find label` +- [x] `find placeholder` +- [x] `find alt` +- [x] `find title` +- [x] `find testid` +- [x] `find first` +- [x] `find last` +- [x] `find nth` + +### Mouse / keyboard namespaces +- [x] `mouse move` +- [x] `mouse down` +- [x] `mouse up` +- [x] `mouse wheel` + +### Browser settings +- [x] `set viewport` +- [x] `set device` +- [x] `set geo` +- [x] `set offline` +- [x] `set headers` +- [x] `set credentials | set auth` +- [x] `set media` (color scheme + reduced-motion parity) + +### Cookies / storage / tabs +- [x] `cookies` +- [x] `cookies set` +- [x] `cookies clear` +- [x] `storage local` +- [x] `storage session` +- [x] `tab` +- [x] `tab new` +- [x] `tab close` + +### Possible later +- [ ] `clipboard` +- [ ] `batch` +- [ ] `network ...` +- [ ] `inspect` +- [ ] `console` +- [ ] `errors` +- [ ] `highlight` + +## Test expansion plan + +### Phase 1 — command contract tests +- [x] add `tests/command.test.ts` +- [x] cover existing commands with fake `Browser` / `Page` +- [x] add tests for new aliases and error cases + +### Phase 2 — page/integration coverage +- [x] drag + upload fixtures +- [x] semantic locator integration tests +- [x] device / screenshot selector coverage +- [x] scroll direction + scroll-into-view coverage + +### Phase 3 — docs +- [ ] update `packages/browser/README.md` +- [ ] update `browse --help` output diff --git a/packages/browser/README.md b/packages/browser/README.md new file mode 100644 index 0000000..741a8b0 --- /dev/null +++ b/packages/browser/README.md @@ -0,0 +1,232 @@ +# @office-agents/browser + +Browser-native CDP (Chrome DevTools Protocol) client for cloud browser automation. **Zero Node.js dependencies** — works in any browser context including Office Add-in taskpanes. + +## Architecture + +The core primitive is a **CDP WebSocket URL**. Any provider that gives you one works: + +``` +Your browser (Office taskpane, web app, etc.) + → WebSocket to wss:// + → CDP JSON-RPC messages + → Cloud browser instance +``` + +## Providers + +The `BrowserProvider` interface abstracts session creation. The CDP URL is the universal handoff point — any cloud browser provider that exposes CDP works. + +| Provider | Status | +|----------|--------| +| [Browserbase](https://browserbase.com) | ✅ Built-in | +| [Browser Use](https://browser-use.com) | ✅ Built-in | +| Any CDP URL | ✅ `Browser.connect()` | +| Custom | ✅ Implement `BrowserProvider` | + +## Usage + +### With Browserbase + +```typescript +import { Browser, BrowserbaseProvider } from "@office-agents/browser"; + +const provider = new BrowserbaseProvider({ + apiKey: "bb-api-...", + projectId: "proj-...", +}); + +const browser = await Browser.launch({ provider }); + +// Navigate +await browser.page.goto("https://example.com"); + +// Get the simplified page snapshot with stable element refs +const snapshot = await browser.page.snapshot(); +console.log(snapshot.tree); +// Example output from https://example.com on 2026-03-22: +// - div +// - heading "Example Domain" [ref=e1] +// - paragraph +// - StaticText "This domain is for use in documentation examples without needing permission. Avoid use in operations." +// - paragraph +// - link "Learn more" [ref=e2] + +// Click by ref from snapshot +await browser.page.clickRef("e2"); + +// Screenshot +const { base64 } = await browser.page.screenshot(); + +// Type text +await browser.page.type("hello world"); + +// Press keys +await browser.page.pressKey("Enter"); +await browser.page.pressKey("Cmd+A"); + +// Evaluate JavaScript +const title = await browser.page.evaluate("document.title"); + +// Clean up +await browser.close(); +``` + +### With Browser Use + +```typescript +import { Browser, BrowserUseProvider } from "@office-agents/browser"; + +const provider = new BrowserUseProvider({ + apiKey: "bu-api-...", +}); + +const browser = await Browser.launch({ + provider, + cdpOptions: { requestTimeoutMs: 10_000 }, +}); + +await browser.page.goto("https://example.com"); +const snapshot = await browser.page.snapshot(); +console.log(snapshot.tree); +await browser.close(); +``` + +### With any CDP URL + +```typescript +import { Browser } from "@office-agents/browser"; + +const browser = await Browser.connect({ + cdpUrl: "wss://some-provider.com/cdp?token=...", +}); + +await browser.page.goto("https://example.com"); +await browser.close(); +``` + +### Low-level CDP access + +```typescript +import { CdpClient } from "@office-agents/browser"; + +const cdp = await CdpClient.connect("wss://...", { + requestTimeoutMs: 10_000, +}); + +// Root/browser-scoped commands work directly on the client +const { targetInfos } = await cdp.api.Target.getTargets(); +console.log(targetInfos.length); + +// Attach to a page target for Page.* / Runtime.* domains +const page = await cdp.attachToFirstPage(); +const session = page.cdpSession!; + +await session.api.Page.navigate({ url: "https://example.com" }); + +session.api.Page.on("loadEventFired", () => { + console.log("Page loaded"); +}); + +const evalResult = await session.api.Runtime.evaluate({ + expression: "document.title", + returnByValue: true, +}); +console.log(evalResult.result?.value); + +const { data } = await session.api.Page.captureScreenshot({ format: "png" }); + +await cdp.close(); +``` + +## API + +### Browser + +- `Browser.launch({ provider, sessionOptions?, cdpOptions? })` — Create a cloud browser session via provider and auto-attach to the first page target +- `Browser.connect({ cdpUrl, cdpOptions? })` — Connect directly to any CDP WebSocket URL and auto-attach to the first page target +- `browser.page` — The active `Page` instance +- `browser.close()` — Close browser and release session + +### CdpClient + +- `CdpClient.connect(wsUrl, { requestTimeoutMs? })` — Connect to a CDP WebSocket +- `cdp.send("Target.getTargets")` — Send typed root/browser-scoped CDP commands using method strings from `devtools-protocol` +- `cdp.api.Target.getTargets()` — Generated domain proxy API typed from `devtools-protocol/types/protocol-proxy-api` +- `cdp.attachToFirstPage()` / `cdp.attachToTarget(targetId)` — Ergonomic helpers that attach to a page target and return a `Page` +- `cdp.releaseSession(sessionId)` — Drop a detached target session from the local session cache + +### Page + +**Navigation:** +- `page.goto(url, { waitUntil?, timeoutMs? })` — Navigate to URL +- `page.reload()` — Reload page +- `page.goBack()` / `page.goForward()` — History navigation + +**State:** +- `page.snapshot()` — Simplified accessibility/DOM snapshot with refs like `e1`, `e2` +- `page.screenshot({ fullPage?, format?, quality? })` — Visual screenshot as base64 +- `page.getUrl()` / `page.getTitle()` / `page.getInfo()` +- `page.getText(selector?)` / `page.getHtml(selector?)` + +**Interaction:** +- `page.clickRef(ref)` — Click element by ref from snapshot +- `page.cdpSession` — The attached target session when the page came from `Browser` or `cdp.attachTo...()` +- `page.click(x, y, { button?, clickCount? })` — Click at coordinates +- `page.type(text, { delay? })` — Type text +- `page.pressKey(key)` — Press key or combo (`"Enter"`, `"Cmd+A"`, `"Ctrl+C"`) +- `page.fill(selector, value, { pressEnter? })` — Fill input field +- `page.hover(x, y)` — Hover at coordinates +- `page.scroll(x, y, deltaX, deltaY)` — Scroll + +**Other:** +- `page.evaluate(expression)` — Execute JavaScript in page +- `page.setViewport(width, height, { deviceScaleFactor? })` +- `page.waitForSelector(selector, timeoutMs?)` +- `page.waitForTimeout(ms)` + +### BrowserProvider + +Implement this interface to add a new cloud browser provider: + +```typescript +interface BrowserProvider { + name: string; + createSession(options?: CreateSessionOptions): Promise; + closeSession(sessionId: string): Promise; +} + +interface BrowserSession { + cdpUrl: string; // The WebSocket URL — the universal primitive + sessionId: string; + metadata?: Record; +} +``` + +## How it works + +The high-level `browse` command shape and CLI ergonomics are adapted from [`vercel-labs/agent-browser`](https://github.com/vercel-labs/agent-browser), while the implementation here is rewritten around direct CDP calls and **browser-native `WebSocket`** transport instead of a Node-specific client. + +A quick live sanity check on 2026-03-22 against Browser Use + `https://example.com` produced this snapshot: + +```text +- div + - heading "Example Domain" [ref=e1] + - paragraph + - StaticText "This domain is for use in documentation examples without needing permission. Avoid use in operations." + - paragraph + - link "Learn more" [ref=e2] +``` + +Every command maps to CDP protocol calls: + +| Command | CDP Methods | +|---------|------------| +| `goto` | `Page.navigate` | +| `click` | `Input.dispatchMouseEvent` | +| `type` | `Input.dispatchKeyEvent` | +| `screenshot` | `Page.captureScreenshot` | +| `snapshot` | `Accessibility.getFullAXTree` + `DOM.getDocument` | +| `evaluate` | `Runtime.evaluate` | +| `viewport` | `Emulation.setDeviceMetricsOverride` | +| `back/forward` | `Page.getNavigationHistory` + `Page.navigateToHistoryEntry` | diff --git a/packages/browser/package.json b/packages/browser/package.json new file mode 100644 index 0000000..a7dc03e --- /dev/null +++ b/packages/browser/package.json @@ -0,0 +1,49 @@ +{ + "name": "@office-agents/browser", + "version": "0.0.1", + "type": "module", + "description": "Browser-native CDP client for cloud browser automation. Works in any browser context — no Node.js required.", + "license": "MIT", + "main": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + } + }, + "repository": { + "type": "git", + "url": "https://github.com/hewliyang/office-agents.git", + "directory": "packages/browser" + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "typecheck": "tsc --noEmit", + "build": "tsc", + "test": "vitest run --exclude tests/integration.test.ts --exclude tests/integration/", + "test:integration": "vitest run tests/integration.test.ts tests/integration/", + "test:install-browsers": "playwright install chromium" + }, + "dependencies": { + "@mozilla/readability": "^0.6.0", + "@types/turndown": "^5.0.6", + "linkedom": "^0.18.12", + "turndown": "^7.2.2" + }, + "devDependencies": { + "devtools-protocol": "^0.0.1602427", + "playwright": "1.57.0", + "typescript": "^5.8.0" + } +} diff --git a/packages/browser/src/browser.ts b/packages/browser/src/browser.ts new file mode 100644 index 0000000..c03709d --- /dev/null +++ b/packages/browser/src/browser.ts @@ -0,0 +1,617 @@ +import { CdpClient, type CdpClientOptions } from "./cdp.js"; +import { Page } from "./page.js"; +import type { + BrowserProvider, + BrowserSession, + CreateSessionOptions, +} from "./providers/types.js"; + +export interface BrowserDependencies { + connectCdp: (wsUrl: string, options?: CdpClientOptions) => Promise; + attachToFirstPage: (cdp: CdpClient) => Promise; + attachToTarget: (cdp: CdpClient, targetId: string) => Promise; +} + +export interface BrowserOptions { + provider: BrowserProvider; + sessionOptions?: CreateSessionOptions; + cdpOptions?: CdpClientOptions; + deps?: Partial; +} + +export interface ConnectOptions { + cdpUrl: string; + cdpOptions?: CdpClientOptions; + deps?: Partial; +} + +export interface BrowserTab { + index: number; + targetId: string; + url: string; + title: string; + active: boolean; +} + +export interface BrowserPreviewState { + connected: boolean; + live: boolean; + frameBase64: string | null; + tabs: BrowserTab[]; + url: string; + title: string; +} + +interface TargetInfoLike { + targetId: string; + type: string; + url: string; + title: string; +} + +type PreviewListener = (state: BrowserPreviewState) => void; + +const defaultBrowserDependencies: BrowserDependencies = { + connectCdp: (wsUrl, options) => CdpClient.connect(wsUrl, options), + attachToFirstPage: (cdp) => Page.attachToFirstPage(cdp), + attachToTarget: (cdp, targetId) => Page.attachToTarget(cdp, targetId), +}; + +function isTrackableTarget(target: { type?: string }): boolean { + return target.type === "page"; +} + +export class Browser { + private cdp: CdpClient | null = null; + private provider: BrowserProvider | null = null; + private session: BrowserSession | null = null; + private directCdpUrl: string | null = null; + private _page: Page | null = null; + private currentTargetId: string | null = null; + private deps: BrowserDependencies = defaultBrowserDependencies; + private trackedTargets = new Map(); + private trackedTargetOrder: string[] = []; + private previewListeners = new Set(); + private previewState: BrowserPreviewState = { + connected: false, + live: false, + frameBase64: null, + tabs: [], + url: "", + title: "", + }; + private previewCleanup: (() => Promise) | null = null; + private previewGeneration = 0; + private attachQueue: Promise = Promise.resolve(); + private readonly onTargetCreatedBound = (params: unknown) => { + void this.handleTargetCreated(params); + }; + private readonly onTargetInfoChangedBound = (params: unknown) => { + void this.handleTargetInfoChanged(params); + }; + private readonly onTargetDestroyedBound = (params: unknown) => { + void this.handleTargetDestroyed(params); + }; + + private constructor() {} + + static async launch(options: BrowserOptions): Promise { + const browser = new Browser(); + browser.deps = { ...defaultBrowserDependencies, ...options.deps }; + browser.provider = options.provider; + browser.session = await options.provider.createSession( + options.sessionOptions, + ); + try { + browser.cdp = await browser.deps.connectCdp( + browser.session.cdpUrl, + options.cdpOptions, + ); + browser._page = await browser.deps.attachToFirstPage(browser.cdp); + browser.currentTargetId = browser._page.targetId ?? null; + await browser.initialize(); + } catch (err) { + await browser.close(); + throw err; + } + return browser; + } + + static async connect(options: ConnectOptions): Promise { + const browser = new Browser(); + browser.deps = { ...defaultBrowserDependencies, ...options.deps }; + browser.directCdpUrl = options.cdpUrl; + browser.cdp = await browser.deps.connectCdp( + options.cdpUrl, + options.cdpOptions, + ); + browser._page = await browser.deps.attachToFirstPage(browser.cdp); + browser.currentTargetId = browser._page.targetId ?? null; + await browser.initialize(); + return browser; + } + + get page(): Page { + if (!this._page) throw new Error("Browser not connected"); + return this._page; + } + + get sessionId(): string | undefined { + return this.session?.sessionId; + } + + get cdpUrl(): string | undefined { + return this.session?.cdpUrl ?? this.directCdpUrl ?? undefined; + } + + private get cdpClient(): CdpClient { + if (!this.cdp) throw new Error("Browser not connected"); + return this.cdp; + } + + private async initialize(): Promise { + await this.enableTargetDiscovery(); + await this.refreshTargets(); + this.installTargetListeners(); + await this.syncCurrentPageInfo(); + this.previewState.connected = true; + this.emitPreview(); + } + + private async enableTargetDiscovery(): Promise { + await this.cdpClient + .send("Target.setDiscoverTargets", { discover: true }) + .catch(() => {}); + } + + private installTargetListeners(): void { + this.cdpClient.on("Target.targetCreated", this.onTargetCreatedBound); + this.cdpClient.on( + "Target.targetInfoChanged", + this.onTargetInfoChangedBound, + ); + this.cdpClient.on("Target.targetDestroyed", this.onTargetDestroyedBound); + } + + private removeTargetListeners(): void { + if (!this.cdp) return; + this.cdp.off("Target.targetCreated", this.onTargetCreatedBound); + this.cdp.off("Target.targetInfoChanged", this.onTargetInfoChangedBound); + this.cdp.off("Target.targetDestroyed", this.onTargetDestroyedBound); + } + + private async refreshTargets(): Promise { + const currentTargetId = this.currentTargetId; + try { + const { targetInfos } = await this.cdpClient.send("Target.getTargets"); + const nextOrder: string[] = []; + const nextTargets = new Map(); + + for (const target of targetInfos) { + if (!isTrackableTarget(target)) continue; + nextOrder.push(target.targetId); + nextTargets.set(target.targetId, { + url: target.url, + title: target.title, + }); + } + + if (currentTargetId && !nextTargets.has(currentTargetId)) { + nextOrder.push(currentTargetId); + nextTargets.set(currentTargetId, { + url: this.previewState.url, + title: this.previewState.title, + }); + } + + this.trackedTargetOrder = nextOrder; + this.trackedTargets = nextTargets; + } catch { + if (currentTargetId) { + this.upsertTrackedTarget(currentTargetId, { + url: this.previewState.url, + title: this.previewState.title, + }); + } + } + this.emitPreview(); + } + + private upsertTrackedTarget( + targetId: string, + data: { url: string; title: string }, + ): void { + this.trackedTargets.set(targetId, data); + if (!this.trackedTargetOrder.includes(targetId)) { + this.trackedTargetOrder.push(targetId); + } + this.emitPreview(); + } + + private removeTrackedTarget(targetId: string): void { + this.trackedTargets.delete(targetId); + this.trackedTargetOrder = this.trackedTargetOrder.filter( + (id) => id !== targetId, + ); + this.emitPreview(); + } + + getTabsSnapshot(): BrowserTab[] { + return this.trackedTargetOrder + .filter((targetId) => this.trackedTargets.has(targetId)) + .map((targetId, index) => { + const target = this.trackedTargets.get(targetId)!; + return { + index, + targetId, + url: target.url, + title: target.title, + active: targetId === this.currentTargetId, + }; + }); + } + + getPreviewStateSnapshot(): BrowserPreviewState { + return { + ...this.previewState, + connected: this.previewState.connected && !!this.cdp, + tabs: this.getTabsSnapshot(), + url: + this.currentTargetId && this.trackedTargets.has(this.currentTargetId) + ? (this.trackedTargets.get(this.currentTargetId)?.url ?? "") + : this.previewState.url, + title: + this.currentTargetId && this.trackedTargets.has(this.currentTargetId) + ? (this.trackedTargets.get(this.currentTargetId)?.title ?? "") + : this.previewState.title, + }; + } + + subscribePreview(listener: PreviewListener): () => void { + this.previewListeners.add(listener); + listener(this.getPreviewStateSnapshot()); + if (this.previewListeners.size === 1) { + void this.restartPreview(); + } + return () => { + this.previewListeners.delete(listener); + if (this.previewListeners.size === 0) { + void this.stopPreview(); + } + }; + } + + private emitPreview(): void { + const snapshot = this.getPreviewStateSnapshot(); + for (const listener of this.previewListeners) { + try { + listener(snapshot); + } catch {} + } + } + + private async syncCurrentPageInfo(): Promise { + if (!this._page || !this.currentTargetId) return; + try { + const info = await this._page.getInfo(); + this.previewState.url = info.url; + this.previewState.title = info.title; + this.upsertTrackedTarget(this.currentTargetId, info); + return; + } catch {} + + if (!this.trackedTargets.has(this.currentTargetId)) { + this.upsertTrackedTarget(this.currentTargetId, { + url: this.previewState.url, + title: this.previewState.title, + }); + } + } + + private async activateTarget(targetId: string): Promise { + await this.cdpClient + .send("Target.activateTarget", { targetId }) + .catch(() => {}); + } + + private async runExclusive(task: () => Promise): Promise { + const previous = this.attachQueue; + let release!: () => void; + this.attachQueue = new Promise((resolve) => { + release = resolve; + }); + await previous; + try { + return await task(); + } finally { + release(); + } + } + + private async attachToTarget(targetId: string): Promise { + return this.runExclusive(async () => { + const currentPage = this._page; + if ( + currentPage && + this.currentTargetId === targetId && + currentPage.cdpSession && + !currentPage.cdpSession.isDetached + ) { + await this.activateTarget(targetId); + await this.syncCurrentPageInfo(); + await this.restartPreview(); + return currentPage; + } + + const previousSessionId = currentPage?.sessionId; + if (previousSessionId) { + await this.cdpClient + .send("Target.detachFromTarget", { + sessionId: previousSessionId, + }) + .catch(() => {}); + this.cdpClient.releaseSession(previousSessionId, "detached by client"); + } + + const page = await this.deps.attachToTarget(this.cdpClient, targetId); + this._page = page; + this.currentTargetId = targetId; + await this.activateTarget(targetId); + await this.syncCurrentPageInfo(); + await this.restartPreview(); + this.emitPreview(); + return page; + }); + } + + private async followTarget(targetId: string): Promise { + if (!this.cdp || targetId === this.currentTargetId) return; + await this.attachToTarget(targetId).catch(() => {}); + } + + private async handleTargetCreated(params: unknown): Promise { + const target = + params && typeof params === "object" && "targetInfo" in params + ? (params.targetInfo as TargetInfoLike) + : null; + if (!target || !isTrackableTarget(target)) return; + + const alreadyTracked = this.trackedTargets.has(target.targetId); + this.upsertTrackedTarget(target.targetId, { + url: target.url, + title: target.title, + }); + + if (!alreadyTracked && target.targetId !== this.currentTargetId) { + await this.followTarget(target.targetId); + } + } + + private async handleTargetInfoChanged(params: unknown): Promise { + const target = + params && typeof params === "object" && "targetInfo" in params + ? (params.targetInfo as TargetInfoLike) + : null; + if (!target || !isTrackableTarget(target)) return; + + const alreadyTracked = this.trackedTargets.has(target.targetId); + this.upsertTrackedTarget(target.targetId, { + url: target.url, + title: target.title, + }); + + if (!alreadyTracked && target.targetId !== this.currentTargetId) { + await this.followTarget(target.targetId); + } + } + + private async handleTargetDestroyed(params: unknown): Promise { + const targetId = + params && typeof params === "object" && "targetId" in params + ? String(params.targetId) + : ""; + if (!targetId) return; + + const wasActive = targetId === this.currentTargetId; + this.removeTrackedTarget(targetId); + + if (!wasActive) return; + + const nextTargetId = this.trackedTargetOrder[0] ?? null; + if (!nextTargetId) { + this.currentTargetId = null; + this.previewState.url = ""; + this.previewState.title = ""; + this.previewState.frameBase64 = null; + this.previewState.live = false; + await this.stopPreview(); + this.emitPreview(); + return; + } + + await this.followTarget(nextTargetId); + } + + private async stopPreview(): Promise { + this.previewGeneration += 1; + const cleanup = this.previewCleanup; + this.previewCleanup = null; + if (cleanup) { + await cleanup().catch(() => {}); + } + this.previewState.live = false; + this.emitPreview(); + } + + private async restartPreview(): Promise { + if (this.previewListeners.size === 0) return; + + const generation = this.previewGeneration + 1; + this.previewGeneration = generation; + + const cleanup = this.previewCleanup; + this.previewCleanup = null; + if (cleanup) { + await cleanup().catch(() => {}); + } + + const session = this._page?.cdpSession; + if (!session || session.isDetached) { + this.previewState.live = false; + this.previewState.frameBase64 = null; + this.emitPreview(); + return; + } + + const onFrameNavigated = (params: unknown) => { + if (generation !== this.previewGeneration) return; + const frame = + params && typeof params === "object" && "frame" in params + ? (params.frame as { url?: string; parentId?: string }) + : null; + if (!frame || frame.parentId) return; + const url = frame.url ?? ""; + this.previewState.url = url; + if (this.currentTargetId) { + const current = this.trackedTargets.get(this.currentTargetId); + this.upsertTrackedTarget(this.currentTargetId, { + url, + title: current?.title ?? this.previewState.title, + }); + } + this.emitPreview(); + }; + + const onScreencastFrame = (params: unknown) => { + if (generation !== this.previewGeneration) return; + const payload = params as { data?: string; sessionId?: number } | null; + if (!payload?.data) return; + this.previewState.frameBase64 = payload.data; + this.previewState.live = true; + this.emitPreview(); + if (typeof payload.sessionId === "number") { + void session + .send("Page.screencastFrameAck", { sessionId: payload.sessionId }) + .catch(() => {}); + } + }; + + session.on("Page.frameNavigated", onFrameNavigated); + session.on("Page.screencastFrame", onScreencastFrame); + + this.previewCleanup = async () => { + session.off("Page.frameNavigated", onFrameNavigated); + session.off("Page.screencastFrame", onScreencastFrame); + if (!session.isDetached) { + await session.send("Page.stopScreencast").catch(() => {}); + } + }; + + try { + await session.send("Page.startScreencast", { + format: "jpeg", + quality: 80, + maxWidth: 1280, + maxHeight: 720, + everyNthFrame: 1, + }); + this.previewState.live = true; + } catch { + this.previewState.live = false; + } + this.emitPreview(); + } + + async listTabs(): Promise { + await this.refreshTargets(); + return this.getTabsSnapshot(); + } + + async newTab(url = "about:blank"): Promise { + const { targetId } = await this.cdpClient.send("Target.createTarget", { + url, + }); + this.upsertTrackedTarget(targetId, { url, title: "" }); + await this.attachToTarget(targetId); + return this.getTabsSnapshot(); + } + + async switchTab(index: number): Promise { + await this.refreshTargets(); + const tabs = this.getTabsSnapshot(); + const tab = tabs[index]; + if (!tab) { + throw new Error(`No tab at index ${index}`); + } + await this.attachToTarget(tab.targetId); + return this.getTabsSnapshot(); + } + + async closeTab(index?: number): Promise { + await this.refreshTargets(); + const tabs = this.getTabsSnapshot(); + if (!tabs.length) return tabs; + const targetTab = + index === undefined ? tabs.find((tab) => tab.active) : tabs[index]; + if (!targetTab) { + throw new Error( + index === undefined ? "No active tab" : `No tab at index ${index}`, + ); + } + + await this.cdpClient.send("Target.closeTarget", { + targetId: targetTab.targetId, + }); + + this.removeTrackedTarget(targetTab.targetId); + if (targetTab.targetId === this.currentTargetId) { + this.currentTargetId = null; + } + + await this.refreshTargets(); + let remaining = this.getTabsSnapshot(); + if (!remaining.length) { + const { targetId } = await this.cdpClient.send("Target.createTarget", { + url: "about:blank", + }); + this.upsertTrackedTarget(targetId, { url: "about:blank", title: "" }); + await this.attachToTarget(targetId); + return this.getTabsSnapshot(); + } + + if (!remaining.some((tab) => tab.active)) { + const next = remaining[Math.min(targetTab.index, remaining.length - 1)]; + await this.attachToTarget(next.targetId); + remaining = this.getTabsSnapshot(); + } + + return remaining; + } + + async close(): Promise { + await this.stopPreview(); + this.removeTargetListeners(); + if (this.cdp) { + await this.cdp.close(); + this.cdp = null; + } + if (this.provider && this.session) { + await this.provider.closeSession(this.session.sessionId).catch(() => {}); + this.session = null; + } + this.directCdpUrl = null; + this.currentTargetId = null; + this._page = null; + this.trackedTargets.clear(); + this.trackedTargetOrder = []; + this.previewState = { + connected: false, + live: false, + frameBase64: null, + tabs: [], + url: "", + title: "", + }; + this.emitPreview(); + } +} diff --git a/packages/browser/src/cdp.ts b/packages/browser/src/cdp.ts new file mode 100644 index 0000000..39f5b29 --- /dev/null +++ b/packages/browser/src/cdp.ts @@ -0,0 +1,508 @@ +import type { ProtocolMapping } from "devtools-protocol/types/protocol-mapping.js"; +import type { ProtocolProxyApi } from "devtools-protocol/types/protocol-proxy-api.js"; +import { Page } from "./page.js"; + +export type CdpCommands = ProtocolMapping.Commands; +export type CdpEvents = ProtocolMapping.Events; +export type CdpProtocolApi = ProtocolProxyApi.ProtocolApi; + +export interface CdpClientOptions { + requestTimeoutMs?: number; +} + +type CommandMethod = keyof CdpCommands; +type EventMethod = keyof CdpEvents; + +type CommandReturn = CdpCommands[M]["returnType"]; + +type SendArgs = CdpCommands[M]["paramsType"] extends [] + ? [] + : CdpCommands[M]["paramsType"] extends [(infer P)?] + ? [params?: P] + : CdpCommands[M]["paramsType"] extends [infer P] + ? [params: P] + : [params?: Record]; + +type EventParams = CdpEvents[E] extends [ + infer P, + ...unknown[], +] + ? P + : undefined; + +export type CdpEventHandler = ( + params: EventParams, +) => void; + +interface CdpResponseMessage { + id: number; + sessionId?: string; + result?: unknown; + error?: { code: number; message: string; data?: string }; +} + +interface CdpEventMessage { + method: string; + params?: unknown; + sessionId?: string; +} + +interface InflightRequest { + resolve: (result: unknown) => void; + reject: (error: Error) => void; + method: string; +} + +interface CdpCommandTarget { + send( + method: M, + ...args: SendArgs + ): Promise>; + on(event: E, handler: CdpEventHandler): void; + off(event: E, handler: CdpEventHandler): void; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeError(error: unknown, prefix: string): Error { + if (error instanceof Error) { + return new Error(`${prefix}: ${error.message}`); + } + return new Error(`${prefix}: ${String(error)}`); +} + +function formatCdpError( + method: string, + error: { code: number; message: string; data?: string }, +): Error { + const suffix = error.data ? ` (${error.data})` : ""; + return new Error( + `CDP ${method} failed (${error.code}): ${error.message}${suffix}`, + ); +} + +function sendDynamic( + target: CdpCommandTarget, + method: string, + params?: unknown, +): Promise { + if (params === undefined) { + return target.send(method as CommandMethod) as Promise; + } + return target.send( + method as CommandMethod, + params as SendArgs[0], + ) as Promise; +} + +function createProtocolApi(target: CdpCommandTarget): CdpProtocolApi { + const domainCache = new Map(); + + return new Proxy({} as CdpProtocolApi, { + get(_protocol, domainName) { + if (typeof domainName !== "string") return undefined; + + let domain = domainCache.get(domainName); + if (!domain) { + domain = new Proxy( + {}, + { + get(_domain, memberName) { + if (typeof memberName !== "string") return undefined; + if (memberName === "on") { + return (event: string, listener: (params: unknown) => void) => { + target.on( + `${domainName}.${event}` as EventMethod, + listener as CdpEventHandler, + ); + }; + } + if (memberName === "off") { + return (event: string, listener: (params: unknown) => void) => { + target.off( + `${domainName}.${event}` as EventMethod, + listener as CdpEventHandler, + ); + }; + } + return (params?: unknown) => + sendDynamic(target, `${domainName}.${memberName}`, params); + }, + }, + ); + domainCache.set(domainName, domain); + } + + return domain; + }, + }); +} + +function sendRequest(options: { + id: number; + method: M; + params: SendArgs[0] | undefined; + sessionId?: string; + store: Map; + timeoutMs?: number; + sendRaw: (payload: Record) => void; +}): Promise> { + const payload: Record = { + id: options.id, + method: options.method, + }; + if (options.params !== undefined) payload.params = options.params; + if (options.sessionId) payload.sessionId = options.sessionId; + + return new Promise((resolve, reject) => { + let timeoutHandle: ReturnType | undefined; + + const cleanup = () => { + if (timeoutHandle !== undefined) { + clearTimeout(timeoutHandle); + timeoutHandle = undefined; + } + }; + + const inflight: InflightRequest = { + method: options.method, + resolve: (result) => { + cleanup(); + resolve(result as CommandReturn); + }, + reject: (error) => { + cleanup(); + reject(error); + }, + }; + + options.store.set(options.id, inflight); + + if (options.timeoutMs && options.timeoutMs > 0) { + timeoutHandle = setTimeout(() => { + if (options.store.get(options.id) !== inflight) return; + options.store.delete(options.id); + inflight.reject( + new Error( + `CDP ${options.method} timed out after ${options.timeoutMs}ms`, + ), + ); + }, options.timeoutMs); + } + + try { + options.sendRaw(payload); + } catch (error) { + options.store.delete(options.id); + inflight.reject( + normalizeError(error, `Failed to send CDP ${options.method}`), + ); + } + }); +} + +export class CdpClient { + private ws: WebSocket; + private nextId = 1; + private inflight = new Map(); + private eventHandlers = new Map>>(); + private sessions = new Map(); + private closeHandlers = new Set<(reason: string) => void>(); + private requestTimeoutMs?: number; + + readonly api: CdpProtocolApi; + + private constructor(ws: WebSocket, options?: CdpClientOptions) { + this.ws = ws; + this.requestTimeoutMs = options?.requestTimeoutMs; + this.api = createProtocolApi(this); + + ws.onmessage = (event) => this.onMessage(event); + ws.onclose = (event) => { + const reason = `close code=${event.code} reason=${event.reason || ""}`; + this.rejectAllInflight(reason); + for (const handler of this.closeHandlers) { + try { + handler(reason); + } catch {} + } + }; + ws.onerror = () => { + this.rejectAllInflight("websocket error"); + }; + } + + static connect( + wsUrl: string, + options?: CdpClientOptions, + ): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(wsUrl); + ws.onopen = () => resolve(new CdpClient(ws, options)); + ws.onerror = (event) => { + reject(normalizeError(event, "CDP WebSocket connection failed")); + }; + }); + } + + send( + method: M, + ...args: SendArgs + ): Promise> { + const id = this.nextId++; + return sendRequest({ + id, + method, + params: args[0], + store: this.inflight, + timeoutMs: this.requestTimeoutMs, + sendRaw: (payload) => this.sendRaw(payload), + }); + } + + on(event: E, handler: CdpEventHandler): void { + const set = this.eventHandlers.get(event) ?? new Set(); + set.add(handler as CdpEventHandler); + this.eventHandlers.set(event, set); + } + + off(event: E, handler: CdpEventHandler): void { + const set = this.eventHandlers.get(event); + if (!set) return; + set.delete(handler as CdpEventHandler); + if (set.size === 0) { + this.eventHandlers.delete(event); + } + } + + onClose(handler: (reason: string) => void): void { + this.closeHandlers.add(handler); + } + + offClose(handler: (reason: string) => void): void { + this.closeHandlers.delete(handler); + } + + session(sessionId: string): CdpSession { + let session = this.sessions.get(sessionId); + if (!session) { + session = new CdpSession(this, sessionId); + this.sessions.set(sessionId, session); + } + return session; + } + + async attachToFirstPage(): Promise { + return Page.attachToFirstPage(this); + } + + async attachToTarget(targetId: string): Promise { + return Page.attachToTarget(this, targetId); + } + + releaseSession(sessionId: string, reason = "session released"): void { + this.detachSession(sessionId, reason); + } + + setRequestTimeout(timeoutMs?: number): void { + this.requestTimeoutMs = timeoutMs; + } + + get defaultRequestTimeoutMs(): number | undefined { + return this.requestTimeoutMs; + } + + async close(): Promise { + if ( + this.ws.readyState === WebSocket.CLOSING || + this.ws.readyState === WebSocket.CLOSED + ) { + return; + } + this.ws.close(); + } + + get readyState(): number { + return this.ws.readyState; + } + + sendRaw(payload: Record): void { + if (this.ws.readyState !== WebSocket.OPEN) { + throw new Error("CDP WebSocket is not open"); + } + this.ws.send(JSON.stringify(payload)); + } + + private onMessage(event: MessageEvent): void { + let data: Partial; + try { + data = JSON.parse(typeof event.data === "string" ? event.data : ""); + } catch { + return; + } + + if (typeof data.id === "number") { + const inflight = data.sessionId + ? this.sessions.get(data.sessionId)?.consumeInflight(data.id) + : this.inflight.get(data.id); + + if (inflight) { + if (!data.sessionId) this.inflight.delete(data.id); + if (data.error) { + inflight.reject(formatCdpError(inflight.method, data.error)); + } else { + inflight.resolve(data.result); + } + } + return; + } + + if (typeof data.method !== "string") { + return; + } + + if (data.method === "Target.detachedFromTarget") { + const sessionId = + isRecord(data.params) && typeof data.params.sessionId === "string" + ? data.params.sessionId + : undefined; + if (sessionId) { + this.detachSession(sessionId, "target detached"); + } + } + + if (data.sessionId) { + this.sessions + .get(data.sessionId) + ?.dispatchEvent(data.method, data.params); + } + + const handlers = this.eventHandlers.get(data.method); + if (handlers) { + for (const handler of handlers) { + try { + (handler as (params: unknown) => void)(data.params); + } catch {} + } + } + } + + private detachSession(sessionId: string, reason: string): void { + const session = this.sessions.get(sessionId); + if (!session) return; + this.sessions.delete(sessionId); + session.markDetached(reason); + } + + private rejectAllInflight(reason: string): void { + for (const request of this.inflight.values()) { + request.reject( + new Error( + `CDP connection closed (${reason}), pending: ${request.method}`, + ), + ); + } + this.inflight.clear(); + + for (const [sessionId, session] of this.sessions) { + session.markDetached(reason); + this.sessions.delete(sessionId); + } + } +} + +export class CdpSession { + private nextId = 1; + private inflight = new Map(); + private eventHandlers = new Map>>(); + private detachedReason: string | null = null; + + readonly api: CdpProtocolApi; + + constructor( + private root: CdpClient, + readonly id: string, + ) { + this.api = createProtocolApi(this); + } + + get isDetached(): boolean { + return this.detachedReason !== null; + } + + send( + method: M, + ...args: SendArgs + ): Promise> { + if (this.detachedReason) { + return Promise.reject( + new Error( + `CDP session ${this.id} is detached (${this.detachedReason})`, + ), + ); + } + + const id = this.nextId++; + return sendRequest({ + id, + method, + params: args[0], + sessionId: this.id, + store: this.inflight, + timeoutMs: this.root.defaultRequestTimeoutMs, + sendRaw: (payload) => this.root.sendRaw(payload), + }); + } + + on(event: E, handler: CdpEventHandler): void { + const set = this.eventHandlers.get(event) ?? new Set(); + set.add(handler as CdpEventHandler); + this.eventHandlers.set(event, set); + } + + off(event: E, handler: CdpEventHandler): void { + const set = this.eventHandlers.get(event); + if (!set) return; + set.delete(handler as CdpEventHandler); + if (set.size === 0) { + this.eventHandlers.delete(event); + } + } + + consumeInflight(id: number): InflightRequest | undefined { + const request = this.inflight.get(id); + if (request) { + this.inflight.delete(id); + } + return request; + } + + dispatchEvent(method: string, params: unknown): void { + const handlers = this.eventHandlers.get(method); + if (!handlers) return; + + for (const handler of handlers) { + try { + (handler as (payload: unknown) => void)(params); + } catch {} + } + } + + markDetached(reason: string): void { + if (this.detachedReason) return; + this.detachedReason = reason; + this.rejectAll(reason); + this.eventHandlers.clear(); + } + + rejectAll(reason: string): void { + for (const request of this.inflight.values()) { + request.reject( + new Error(`CDP session closed (${reason}), pending: ${request.method}`), + ); + } + this.inflight.clear(); + } +} diff --git a/packages/browser/src/command.ts b/packages/browser/src/command.ts new file mode 100644 index 0000000..362f3ed --- /dev/null +++ b/packages/browser/src/command.ts @@ -0,0 +1,2041 @@ +import { + Browser, + type BrowserOptions, + type BrowserPreviewState, +} from "./browser.js"; +import type { BrowserProvider } from "./providers/types.js"; + +export interface BrowseSessionEvent { + active: boolean; + sessionId?: string; +} + +export interface BrowsePreviewEvent extends BrowserPreviewState { + active: boolean; + sessionId?: string; +} + +type BrowseSessionListener = (event: BrowseSessionEvent) => void; +type BrowsePreviewListener = (event: BrowsePreviewEvent) => void; + +export interface BrowseCommandConfig { + getProvider: () => BrowserProvider | null; + writeFile?: (path: string, data: Uint8Array) => Promise; + readFile?: (path: string) => Promise; + launchBrowser?: (options: BrowserOptions) => Promise; + connectBrowser?: (options: { cdpUrl: string }) => Promise; +} + +export class BrowseCli { + private activeBrowser: Browser | null = null; + private readonly sessionListeners = new Set(); + private readonly previewListeners = new Set(); + private previewBridgeCleanup: (() => void) | null = null; + private previewState: BrowsePreviewEvent = { + active: false, + connected: false, + live: false, + frameBase64: null, + tabs: [], + url: "", + title: "", + }; + private lifecycleCleanupInstalled = false; + private config: BrowseCommandConfig | null = null; + + constructor(config?: BrowseCommandConfig) { + if (config) this.configure(config); + } + + onSessionChange(listener: BrowseSessionListener): () => void { + this.sessionListeners.add(listener); + return () => this.sessionListeners.delete(listener); + } + + onPreviewChange(listener: BrowsePreviewListener): () => void { + this.previewListeners.add(listener); + if (this.previewListeners.size === 1 && this.activeBrowser) { + this.attachPreviewBridge(); + } else { + listener(this.getPreviewState()); + } + return () => { + this.previewListeners.delete(listener); + if (this.previewListeners.size === 0) { + this.detachPreviewBridge(); + this.previewState = this.getCurrentPreviewEvent(); + } + }; + } + + getSessionState(): BrowseSessionEvent { + if (!this.activeBrowser) return { active: false }; + return { + active: true, + sessionId: this.activeBrowser.sessionId, + }; + } + + getPreviewState(): BrowsePreviewEvent { + if (!this.previewBridgeCleanup) { + return this.getCurrentPreviewEvent(); + } + return this.previewState; + } + + getActiveBrowser(): Browser | null { + return this.activeBrowser; + } + + getProvider(): BrowserProvider { + const provider = this.config?.getProvider(); + if (!provider) { + throw new Error( + "No browser provider configured. Set a browser provider in settings.", + ); + } + return provider; + } + + getBrowserOrThrow(): Browser { + if (!this.activeBrowser) { + throw new Error("No browser session. Run 'browse open ' first."); + } + return this.activeBrowser; + } + + setBrowser(browser: Browser | null): void { + this.setActiveBrowser(browser); + } + + getLaunchBrowser(): (options: BrowserOptions) => Promise { + return this.config?.launchBrowser ?? Browser.launch; + } + + getConnectBrowser(): (options: { cdpUrl: string }) => Promise { + return ( + this.config?.connectBrowser ?? ((options) => Browser.connect(options)) + ); + } + + getReadFile(): ((path: string) => Promise) | undefined { + return this.config?.readFile; + } + + getWriteFile(): + | ((path: string, data: Uint8Array) => Promise) + | undefined { + return this.config?.writeFile; + } + + configure(config: BrowseCommandConfig): void { + this.config = config; + this.installLifecycleCleanup(); + } + + async switchTab(index: number): Promise { + if (!this.activeBrowser) { + throw new Error("No browser session. Run 'browse open ' first."); + } + await this.activeBrowser.switchTab(index); + } + + async closeActiveBrowser(): Promise { + const browser = this.activeBrowser; + if (!browser) return; + this.setActiveBrowser(null); + await browser.close().catch(() => {}); + } + + async dispose(): Promise { + this.config = null; + await this.closeActiveBrowser(); + } + + private emitSessionChange(): void { + const event = this.getSessionState(); + for (const listener of this.sessionListeners) { + try { + listener(event); + } catch {} + } + } + + private getCurrentPreviewEvent( + browser: Browser | null = this.activeBrowser, + ): BrowsePreviewEvent { + if (!browser) { + return { + active: false, + connected: false, + live: false, + frameBase64: null, + tabs: [], + url: "", + title: "", + }; + } + + return { + active: true, + sessionId: browser.sessionId, + ...browser.getPreviewStateSnapshot(), + }; + } + + private emitPreviewChange(event = this.getPreviewState()): void { + this.previewState = event; + for (const listener of this.previewListeners) { + try { + listener(event); + } catch {} + } + } + + private attachPreviewBridge(): void { + if ( + this.previewBridgeCleanup || + this.previewListeners.size === 0 || + !this.activeBrowser + ) { + return; + } + + this.previewBridgeCleanup = this.activeBrowser.subscribePreview((state) => { + this.emitPreviewChange({ + active: true, + sessionId: this.activeBrowser?.sessionId, + ...state, + }); + }); + } + + private detachPreviewBridge(): void { + if (!this.previewBridgeCleanup) return; + this.previewBridgeCleanup(); + this.previewBridgeCleanup = null; + } + + private setActiveBrowser(browser: Browser | null): void { + this.detachPreviewBridge(); + this.activeBrowser = browser; + this.emitSessionChange(); + if (this.activeBrowser && this.previewListeners.size > 0) { + this.attachPreviewBridge(); + } else { + this.emitPreviewChange(this.getCurrentPreviewEvent(browser)); + } + } + + private installLifecycleCleanup(): void { + if (this.lifecycleCleanupInstalled || typeof window === "undefined") { + return; + } + this.lifecycleCleanupInstalled = true; + + const cleanup = () => { + void this.closeActiveBrowser(); + }; + + window.addEventListener("pagehide", cleanup); + window.addEventListener("beforeunload", cleanup); + } + + async executeCommand( + args: string[], + ): Promise<{ stdout: string; stderr: string; exitCode: number }> { + return executeBrowseCommandWith(this, args); + } +} + +const HELP = `Usage: browse [options] + +Tips: + The browse session stays alive across commands in the same shell/runtime. + Chain commands in one bash call when possible, e.g.: + browse open hewliyang.com && browse markdown hewliyang.md + For harvesting multiple URLs, prefer tabs in one shell call, e.g.: + browse open site1.com && browse tab new site2.com && browse tab 0 && browse markdown site1.md && browse tab 1 && browse markdown site2.md + Do not chain snapshot refs blindly; read the snapshot output first, then use refs like @e2. + +Core: + open [--wait=load|domcontentloaded|networkidle] [--timeout=ms] + goto + navigate + connect + snapshot [-i|--interactive] [-c|--compact] [-d N|--depth=N] + click [--new-tab] + dblclick + focus + type + type Type into the currently focused element + fill [--no-enter] + press + key + keydown + keyup + keyboard type + keyboard inserttext + hover | hover + check + uncheck + select + drag + upload + eval + +Get: + get url + get title + get text [ref|selector] + get html [ref|selector] + get value + get attr + get count + get box + get styles + get cdp-url + +State: + is visible + is enabled + is checked + +Find: + find role [value] [--name ] [--exact] + find text + find label