diff --git a/.gitignore b/.gitignore index 204a7e7..344130c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,6 @@ _scratch/ !bin/dev-browser.js -!daemon/dist/daemon.bundle.mjs -!daemon/dist/sandbox-client.js .dev-browser/ .DS_Store .env diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5160889 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,28 @@ +# CLAUDE.md + +This repository ships `dev-browser`: a Rust CLI plus a Node.js daemon for browser automation with a QuickJS sandbox. Use this file as the repo-specific guide when making code changes. + +## Tooling + +- Use Node.js tooling for `daemon/` and Cargo for `cli/`. Do not use Bun. +- The daemon package uses `pnpm`. +- The repo root contains packaging glue (`bin/`, `scripts/`, `README.md`), but most runtime behavior lives in `cli/` and `daemon/`. + +## Validation + +Run these before finishing changes that touch runtime code: + +```bash +cd daemon && npx tsc --noEmit +cd daemon && pnpm vitest run +cd cli && cargo build +``` + +If you change daemon runtime code that is embedded into the Rust binary, rebuild the bundles first: + +```bash +cd daemon && pnpm bundle +cd daemon && pnpm bundle:sandbox-client +``` + +`cli/src/daemon.rs` embeds `daemon/dist/daemon.bundle.mjs` and `daemon/dist/sandbox-client.js` via `include_str!`, so `cargo build` only sees the latest daemon changes after those bundles are regenerated. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index f492327..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,102 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Build and Development Commands - -Always use Node.js/npm instead of Bun. - -```bash -# Install dependencies (from skills/dev-browser/ directory) -cd skills/dev-browser && npm install - -# Start the dev-browser server -cd skills/dev-browser && npm run start-server - -# Run dev mode with watch -cd skills/dev-browser && npm run dev - -# Run tests (uses vitest) -cd skills/dev-browser && npm test - -# Run TypeScript check -cd skills/dev-browser && npx tsc --noEmit -``` - -## Important: Before Completing Code Changes - -**Always run these checks before considering a task complete:** - -1. **TypeScript check**: `npx tsc --noEmit` - Ensure no type errors -2. **Tests**: `npm test` - Ensure all tests pass - -Common TypeScript issues in this codebase: - -- Use `import type { ... }` for type-only imports (required by `verbatimModuleSyntax`) -- Browser globals (`document`, `window`) in `page.evaluate()` callbacks need `declare const document: any;` since DOM lib is not included - -## Project Architecture - -### Overview - -This is a browser automation tool designed for developers and AI agents. It solves the problem of maintaining browser state across multiple script executions - unlike Playwright scripts that start fresh each time, dev-browser keeps pages alive and reusable. - -### Structure - -All source code lives in `skills/dev-browser/`: - -- `src/index.ts` - Server: launches persistent Chromium context, exposes HTTP API for page management -- `src/client.ts` - Client: connects to server, retrieves pages by name via CDP -- `src/types.ts` - Shared TypeScript types for API requests/responses -- `src/dom/` - DOM tree extraction utilities for LLM-friendly page inspection -- `scripts/start-server.ts` - Entry point to start the server -- `tmp/` - Directory for temporary automation scripts - -### Path Aliases - -The project uses `@/` as a path alias to `./src/`. This is configured in both `package.json` (via `imports`) and `tsconfig.json` (via `paths`). - -```typescript -// Import from src/client.ts -import { connect } from "@/client.js"; - -// Import from src/index.ts -import { serve } from "@/index.js"; -``` - -### How It Works - -1. **Server** (`serve()` in `src/index.ts`): - - Launches Chromium with `launchPersistentContext` (preserves cookies, localStorage) - - Exposes HTTP API on port 9222 for page management - - Exposes CDP WebSocket endpoint on port 9223 - - Pages are registered by name and persist until explicitly closed - -2. **Client** (`connect()` in `src/client.ts`): - - Connects to server's HTTP API - - Uses CDP `targetId` to reliably find pages across reconnections - - Returns standard Playwright `Page` objects for automation - -3. **Key API Endpoints**: - - `GET /` - Returns CDP WebSocket endpoint - - `GET /pages` - Lists all named pages - - `POST /pages` - Gets or creates a page by name (body: `{ name: string }`) - - `DELETE /pages/:name` - Closes a page - -### Usage Pattern - -```typescript -import { connect } from "@/client.js"; - -const client = await connect("http://localhost:9222"); -const page = await client.page("my-page"); // Gets existing or creates new -await page.goto("https://example.com"); -// Page persists for future scripts -await client.disconnect(); // Disconnects CDP but page stays alive on server -``` - -## Node.js Guidelines - -- Use `npx tsx` for running TypeScript files -- Use `dotenv` or similar if you need to load `.env` files -- Use `node:fs` for file system operations diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/daemon/src/browser-api.ts b/daemon/src/browser-api.ts deleted file mode 100644 index 267e1b5..0000000 --- a/daemon/src/browser-api.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { Page } from "playwright"; -import type { BrowserManager, BrowserPageSummary } from "./browser-manager.js"; - -export interface CleanupTracker { - anonymousPages: Page[]; -} - -export interface ScriptBrowserAPI { - getPage(name: string): Promise; - newPage(): Promise; - listPages(): Promise; - closePage(name: string): Promise; -} - -export function createBrowserAPI( - manager: BrowserManager, - browserName: string, - cleanupTracker: CleanupTracker -): ScriptBrowserAPI { - return { - getPage(name: string): Promise { - return manager.getPage(browserName, name); - }, - - async newPage(): Promise { - const page = await manager.newPage(browserName); - cleanupTracker.anonymousPages.push(page); - return page; - }, - - async listPages(): Promise { - return manager.listPages(browserName); - }, - - closePage(name: string): Promise { - return manager.closePage(browserName, name); - }, - }; -} diff --git a/daemon/src/browser-manager.ts b/daemon/src/browser-manager.ts index 1959d67..fdeb895 100644 --- a/daemon/src/browser-manager.ts +++ b/daemon/src/browser-manager.ts @@ -14,14 +14,14 @@ export interface BrowserEntry { headless: boolean; } -export interface BrowserSummary { +interface BrowserSummary { name: string; type: BrowserEntry["type"]; status: "running" | "connected" | "disconnected"; pages: string[]; } -export interface BrowserPageSummary { +interface BrowserPageSummary { id: string; url: string; title: string; diff --git a/daemon/src/protocol.ts b/daemon/src/protocol.ts index 101fd20..d6dbd0c 100644 --- a/daemon/src/protocol.ts +++ b/daemon/src/protocol.ts @@ -4,7 +4,7 @@ const RequestBaseSchema = z.object({ id: z.string().min(1), }); -export const ExecuteRequestSchema = RequestBaseSchema.extend({ +const ExecuteRequestSchema = RequestBaseSchema.extend({ type: z.literal("execute"), browser: z.string().min(1).default("default"), script: z.string(), @@ -13,28 +13,28 @@ export const ExecuteRequestSchema = RequestBaseSchema.extend({ timeoutMs: z.number().int().positive().optional(), }); -export const BrowsersRequestSchema = RequestBaseSchema.extend({ +const BrowsersRequestSchema = RequestBaseSchema.extend({ type: z.literal("browsers"), }); -export const BrowserStopRequestSchema = RequestBaseSchema.extend({ +const BrowserStopRequestSchema = RequestBaseSchema.extend({ type: z.literal("browser-stop"), browser: z.string().min(1), }); -export const StatusRequestSchema = RequestBaseSchema.extend({ +const StatusRequestSchema = RequestBaseSchema.extend({ type: z.literal("status"), }); -export const InstallRequestSchema = RequestBaseSchema.extend({ +const InstallRequestSchema = RequestBaseSchema.extend({ type: z.literal("install"), }); -export const StopRequestSchema = RequestBaseSchema.extend({ +const StopRequestSchema = RequestBaseSchema.extend({ type: z.literal("stop"), }); -export const RequestSchema = z.discriminatedUnion("type", [ +const RequestSchema = z.discriminatedUnion("type", [ ExecuteRequestSchema, BrowsersRequestSchema, BrowserStopRequestSchema, @@ -47,32 +47,32 @@ const ResponseBaseSchema = z.object({ id: z.string().min(1), }); -export const StdoutMessageSchema = ResponseBaseSchema.extend({ +const StdoutMessageSchema = ResponseBaseSchema.extend({ type: z.literal("stdout"), data: z.string(), }); -export const StderrMessageSchema = ResponseBaseSchema.extend({ +const StderrMessageSchema = ResponseBaseSchema.extend({ type: z.literal("stderr"), data: z.string(), }); -export const CompleteMessageSchema = ResponseBaseSchema.extend({ +const CompleteMessageSchema = ResponseBaseSchema.extend({ type: z.literal("complete"), success: z.literal(true), }); -export const ErrorMessageSchema = ResponseBaseSchema.extend({ +const ErrorMessageSchema = ResponseBaseSchema.extend({ type: z.literal("error"), message: z.string(), }); -export const ResultMessageSchema = ResponseBaseSchema.extend({ +const ResultMessageSchema = ResponseBaseSchema.extend({ type: z.literal("result"), data: z.unknown(), }); -export const ResponseSchema = z.discriminatedUnion("type", [ +const ResponseSchema = z.discriminatedUnion("type", [ StdoutMessageSchema, StderrMessageSchema, CompleteMessageSchema, @@ -80,7 +80,7 @@ export const ResponseSchema = z.discriminatedUnion("type", [ ResultMessageSchema, ]); -export type Request = z.infer; +type Request = z.infer; export type ExecuteRequest = z.infer; export type Response = z.infer; diff --git a/daemon/src/sandbox/quickjs-sandbox.ts b/daemon/src/sandbox/quickjs-sandbox.ts index b8d90cb..b609757 100644 --- a/daemon/src/sandbox/quickjs-sandbox.ts +++ b/daemon/src/sandbox/quickjs-sandbox.ts @@ -167,7 +167,7 @@ function decodeSandboxFilePayload(value: unknown, label: string): string | Uint8 return Buffer.from(data, "base64"); } -export interface QuickJSSandboxOptions { +interface QuickJSSandboxOptions { manager: BrowserManager; browserName: string; onStdout: (data: string) => void; diff --git a/daemon/src/script-runner.ts b/daemon/src/script-runner.ts deleted file mode 100644 index 1ca1efe..0000000 --- a/daemon/src/script-runner.ts +++ /dev/null @@ -1,79 +0,0 @@ -import util from "node:util"; -import vm from "node:vm"; -import type { ScriptBrowserAPI } from "./browser-api.js"; - -interface ScriptOutput { - onStdout: (data: string) => void; - onStderr: (data: string) => void; -} - -function withWallClockTimeout(promise: Promise, timeout: number): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error(`Script timed out after ${timeout}ms`)); - }, timeout); - - promise.then( - (value) => { - clearTimeout(timer); - resolve(value); - }, - (error: unknown) => { - clearTimeout(timer); - reject(error); - } - ); - }); -} - -function formatArgs(args: unknown[]): string { - return args - .map((arg) => - typeof arg === "string" - ? arg - : util.inspect(arg, { - colors: false, - depth: 6, - compact: 3, - breakLength: Infinity, - }) - ) - .join(" "); -} - -export async function runScript( - script: string, - browser: ScriptBrowserAPI, - output: ScriptOutput, - options: { timeout?: number } = {} -): Promise { - const { timeout = 30_000 } = options; - - const routedConsole = { - log: (...args: unknown[]) => output.onStdout(`${formatArgs(args)}\n`), - info: (...args: unknown[]) => output.onStdout(`${formatArgs(args)}\n`), - debug: (...args: unknown[]) => output.onStdout(`${formatArgs(args)}\n`), - warn: (...args: unknown[]) => output.onStderr(`${formatArgs(args)}\n`), - error: (...args: unknown[]) => output.onStderr(`${formatArgs(args)}\n`), - }; - - const context = vm.createContext({ - browser, - console: routedConsole, - setTimeout, - clearTimeout, - setInterval, - clearInterval, - }); - - const wrappedScript = `(async () => {\n${script}\n})()`; - const compiledScript = new vm.Script(wrappedScript, { - filename: "user-script.js", - }); - const result = compiledScript.runInContext(context, { - timeout, - displayErrors: true, - }); - - await withWallClockTimeout(Promise.resolve(result), timeout); -}