diff --git a/.gitignore b/.gitignore index 1fd9cd3..4c0af46 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,10 @@ coverage/ # Temporary files tmp/ temp/ +.npm-install-hash + +# npm install optimization cache +.npm-install-hash # Browser profiles profiles/ diff --git a/skills/dev-browser/SKILL.md b/skills/dev-browser/SKILL.md index 21e4bd4..4f1c368 100644 --- a/skills/dev-browser/SKILL.md +++ b/skills/dev-browser/SKILL.md @@ -15,17 +15,64 @@ Browser automation that maintains page state across script executions. Write sma ## Setup -Two modes available. Ask the user if unclear which to use. +```bash +./skills/dev-browser/server.sh & +``` -### Standalone Mode (Default) +**Wait for the `Ready` message before running scripts.** -Launches a new Chromium browser for fresh automation sessions. +The server: +- Auto-assigns a port from 19222-19300 (avoids Chrome CDP port conflicts) +- Writes the port to `tmp/port` for client discovery +- Outputs `PORT=XXXX` to stdout +- Auto-shuts down after 30 minutes of inactivity +- Cleans up stale server entries on startup -```bash -./skills/dev-browser/server.sh & +The client (`connectLite()`) auto-discovers the port in this order: +1. `DEV_BROWSER_PORT` environment variable +2. `tmp/port` file in skill directory +3. Most recent server from `~/.dev-browser/active-servers.json` +4. Default port 19222 + +The server uses Chrome for Testing via CDP based on configuration at `~/.dev-browser/config.json`: + +- **External Browser** (default): Uses Chrome for Testing via CDP. Browser stays open after automation. +- **Standalone**: Uses Playwright's bundled Chromium. **Not recommended** - only available with explicit `--standalone` flag. + +**Important**: If Chrome for Testing is not found, the server will fail with an error instead of falling back to Playwright's bundled browser. This ensures consistent browser behavior. + +**Flags:** +- `--standalone` - Force standalone Playwright mode (not recommended) +- `--headless` - Run headless (standalone mode only) + +### Configuration + +Browser settings are configured in `~/.dev-browser/config.json`: + +```json +{ + "portRange": { "start": 19222, "end": 19300, "step": 2 }, + "cdpPort": 9223, + "browser": { + "mode": "auto", + "path": "/Applications/Chrome for Testing.app" + } +} ``` -Add `--headless` flag if user requests it. **Wait for the `Ready` message before running scripts.** +| Setting | Values | Description | +|---------|--------|-------------| +| `portRange.start` | Number (default: 19222) | First port to try for HTTP API server | +| `portRange.end` | Number (default: 19300) | Last port to try | +| `cdpPort` | Number (default: 9223) | Chrome DevTools Protocol port | +| `browser.mode` | `"auto"` (default), `"external"`, `"standalone"` | `auto` and `external` use Chrome for Testing; `standalone` uses Playwright (not recommended) | +| `browser.path` | Path string | Browser executable or .app bundle. On macOS, .app paths use `open -a` for proper Dock icon | +| `browser.userDataDir` | Path string | Browser profile directory for external mode (uses browser's default if not set) | + +**Auto-detection paths:** +- **macOS**: `/Applications/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing` +- **Linux**: `/opt/google/chrome-for-testing/chrome`, `/usr/bin/google-chrome-for-testing` +- **Windows**: `C:\Program Files\Google\Chrome for Testing\Application\chrome.exe` ### Extension Mode @@ -59,16 +106,16 @@ Execute scripts inline using heredocs: ```bash cd skills/dev-browser && npx tsx <<'EOF' -import { connect, waitForPageLoad } from "@/client.js"; +import { connectLite } from "@/client-lite.js"; -const client = await connect(); -const page = await client.page("example"); // descriptive name like "cnn-homepage" -await page.setViewportSize({ width: 1280, height: 800 }); +const client = await connectLite(); +await client.page("example"); // descriptive name like "cnn-homepage" +await client.setViewportSize("example", 1280, 800); -await page.goto("https://example.com"); -await waitForPageLoad(page); +await client.navigate("example", "https://example.com"); -console.log({ title: await page.title(), url: page.url() }); +const info = await client.getInfo("example"); +console.log({ title: info.title, url: info.url }); await client.disconnect(); EOF ``` @@ -81,7 +128,7 @@ EOF 2. **Evaluate state**: Log/return state at the end to decide next steps 3. **Descriptive page names**: Use `"checkout"`, `"login"`, not `"main"` 4. **Disconnect to exit**: `await client.disconnect()` - pages persist on server -5. **Plain JS in evaluate**: `page.evaluate()` runs in browser - no TypeScript syntax +5. **Plain JS in evaluate**: `client.evaluate()` runs in browser - no TypeScript syntax ## Workflow Loop @@ -95,19 +142,19 @@ Follow this pattern for complex tasks: ### No TypeScript in Browser Context -Code passed to `page.evaluate()` runs in the browser, which doesn't understand TypeScript: +Code passed to `client.evaluate()` runs in the browser, which doesn't understand TypeScript: ```typescript // ✅ Correct: plain JavaScript -const text = await page.evaluate(() => { - return document.body.innerText; -}); +const text = await client.evaluate("mypage", ` + document.body.innerText +`); // ❌ Wrong: TypeScript syntax will fail at runtime -const text = await page.evaluate(() => { +const text = await client.evaluate("mypage", ` const el: HTMLElement = document.body; // Type annotation breaks in browser! - return el.innerText; -}); + el.innerText; +`); ``` ## Scraping Data @@ -117,27 +164,30 @@ For scraping large datasets, intercept and replay network requests rather than s ## Client API ```typescript -const client = await connect(); -const page = await client.page("name"); // Get or create named page -const pages = await client.list(); // List all page names -await client.close("name"); // Close a page -await client.disconnect(); // Disconnect (pages persist) +import { connectLite } from "@/client-lite.js"; + +const client = await connectLite(); +await client.page("name"); // Get or create named page +const pages = await client.list(); // List all page names +await client.close("name"); // Close a page +await client.disconnect(); // Disconnect (pages persist) // ARIA Snapshot methods -const snapshot = await client.getAISnapshot("name"); // Get accessibility tree -const element = await client.selectSnapshotRef("name", "e5"); // Get element by ref +const snapshot = await client.getAISnapshot("name"); // Get accessibility tree +const refInfo = await client.selectRef("name", "e5"); // Get element info by ref +await client.click("name", "e5"); // Click element by ref +await client.fill("name", "e5", "text"); // Fill input by ref ``` -The `page` object is a standard Playwright Page. - ## Waiting ```typescript -import { waitForPageLoad } from "@/client.js"; +// After navigation +await client.navigate("name", "https://example.com", "networkidle"); -await waitForPageLoad(page); // After navigation -await page.waitForSelector(".results"); // For specific elements -await page.waitForURL("**/success"); // For specific URL +// For specific elements +await client.waitForSelector("name", ".results"); +await client.waitForSelector("name", ".modal", { state: "hidden", timeout: 5000 }); ``` ## Inspecting Page State @@ -145,8 +195,13 @@ await page.waitForURL("**/success"); // For specific URL ### Screenshots ```typescript -await page.screenshot({ path: "tmp/screenshot.png" }); -await page.screenshot({ path: "tmp/full.png", fullPage: true }); +import { writeFileSync } from "fs"; + +const result = await client.screenshot("name"); +writeFileSync("tmp/screenshot.png", Buffer.from(result.screenshot, "base64")); + +const full = await client.screenshot("name", { fullPage: true }); +writeFileSync("tmp/full.png", Buffer.from(full.screenshot, "base64")); ``` ### ARIA Snapshot (Element Discovery) @@ -181,8 +236,13 @@ Use `getAISnapshot()` to discover page elements. Returns YAML-formatted accessib const snapshot = await client.getAISnapshot("hackernews"); console.log(snapshot); // Find the ref you need -const element = await client.selectSnapshotRef("hackernews", "e2"); -await element.click(); +// Get info about an element +const refInfo = await client.selectRef("hackernews", "e2"); +console.log(refInfo); // { found: true, tagName: "A", textContent: "..." } + +// Click or fill +await client.click("hackernews", "e2"); +await client.fill("hackernews", "e10", "search query"); ``` ## Error Recovery @@ -191,16 +251,22 @@ Page state persists after failures. Debug with: ```bash cd skills/dev-browser && npx tsx <<'EOF' -import { connect } from "@/client.js"; +import { connectLite } from "@/client-lite.js"; +import { writeFileSync } from "fs"; + +const client = await connectLite(); +await client.page("hackernews"); + +const shot = await client.screenshot("hackernews"); +writeFileSync("tmp/debug.png", Buffer.from(shot.screenshot, "base64")); -const client = await connect(); -const page = await client.page("hackernews"); +const info = await client.getInfo("hackernews"); +const bodyText = await client.evaluate("hackernews", "document.body.innerText.slice(0, 200)"); -await page.screenshot({ path: "tmp/debug.png" }); console.log({ - url: page.url(), - title: await page.title(), - bodyText: await page.textContent("body").then((t) => t?.slice(0, 200)), + url: info.url, + title: info.title, + bodyText, }); await client.disconnect(); diff --git a/skills/dev-browser/docs/CONCURRENCY.md b/skills/dev-browser/docs/CONCURRENCY.md new file mode 100644 index 0000000..37f8267 --- /dev/null +++ b/skills/dev-browser/docs/CONCURRENCY.md @@ -0,0 +1,177 @@ +# Multi-Agent Concurrency Support + +This document explains how dev-browser supports multiple concurrent agents and the design decisions behind the implementation. + +## The Problem + +When multiple AI agents (e.g., Claude Code sub-agents) run browser automation tasks in parallel, they need to avoid conflicts. The original dev-browser design assumed a single server on a fixed port, which creates a bottleneck: + +> "dev-browser is in fact a single point of congestion now, nullifying the advantages of dev browser" +> — [PR #15 discussion](https://github.com/SawyerHood/dev-browser/pull/15#issuecomment-3698722432) + +## Solution: Dynamic Port Allocation + +Each agent automatically gets its own HTTP API server on a unique port: + +``` +Agent 1 ──► server (port 9222) ──┐ +Agent 2 ──► server (port 9224) ──┼──► Shared Browser (CDP 9223) +Agent 3 ──► server (port 9226) ──┘ +``` + +### How It Works + +1. **Port Auto-Assignment**: When `port` is not specified, the server finds an available port in the configured range (default: 9222-9300, step 2) + +2. **Port Discovery**: Server outputs `PORT=XXXX` to stdout, which agents parse to know which port to connect to + +3. **Server Tracking**: Active servers are tracked in `~/.dev-browser/active-servers.json` for coordination + +4. **Shared Browser**: In external browser mode, all servers connect to the same browser via CDP, minimizing resource usage + +## Design Decisions + +### Options Considered + +#### Option 1: Manual Port Assignment (Rejected) + +From [PR #15](https://github.com/SawyerHood/dev-browser/pull/15), the initial proposal was to add `--port` and `--cdp-port` CLI flags for manual assignment. + +**Why rejected**: Requires agents to coordinate port selection, adds complexity to agent implementation, and creates potential for conflicts. + +#### Option 2: Singleton Server with Named Pages (Rejected) + +Have one persistent server handling all agents, using page names for isolation. + +**Why rejected**: Incompatible with the plugin architecture where each agent spawns its own server process. Also creates a true single point of failure. + +#### Option 3: Dynamic Port Allocation (Chosen) + +Servers automatically discover and claim available ports. + +**Why chosen**: +- Zero configuration required +- Agents don't need to coordinate +- Works with existing plugin architecture +- Each agent is isolated (failure doesn't affect others) +- Memory overhead is acceptable (~140MB per server) + +### Memory Considerations + +Each dev-browser server uses approximately: +- **Node.js + Playwright + Express**: ~140MB +- **Browser (if standalone mode)**: ~300MB additional + +In external browser mode, multiple servers share one browser, making the per-agent overhead just ~140MB. + +## Configuration + +Create `~/.dev-browser/config.json` to customize behavior: + +```json +{ + "portRange": { + "start": 9222, + "end": 9300, + "step": 2 + }, + "cdpPort": 9223 +} +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `portRange.start` | 9222 | First port to try for HTTP API | +| `portRange.end` | 9300 | Last port to try | +| `portRange.step` | 2 | Port increment (avoids CDP port collision) | +| `cdpPort` | 9223 | Chrome DevTools Protocol port | + +## Usage Examples + +### Multiple Agents (External Browser Mode) + +```bash +# Terminal 1: Start Chrome for Testing, then: +BROWSER_PATH="/path/to/chrome" npx tsx scripts/start-external-browser.ts +# Output: PORT=9222 + +# Terminal 2: Second agent +npx tsx scripts/start-external-browser.ts +# Output: PORT=9224 + +# Terminal 3: Third agent +npx tsx scripts/start-external-browser.ts +# Output: PORT=9226 + +# All agents share the same browser on CDP port 9223 +``` + +### Multiple Agents (Standalone Mode) + +```bash +# Terminal 1: First agent launches its own browser +npx tsx scripts/start-server.ts +# Output: PORT=9222 + +# Terminal 2: Second agent launches separate browser +npx tsx scripts/start-server.ts +# Output: PORT=9224 +``` + +### Programmatic Usage + +```typescript +import { serve, serveWithExternalBrowser } from "dev-browser"; + +// Port is automatically assigned +const server1 = await serve(); // Gets port 9222 +const server2 = await serve(); // Gets port 9224 + +console.log(`Server 1 on port ${server1.port}`); +console.log(`Server 2 on port ${server2.port}`); + +// Or with external browser +const external1 = await serveWithExternalBrowser(); +const external2 = await serveWithExternalBrowser(); +// Both connect to same browser on CDP 9223 +``` + +## Troubleshooting + +### "No available ports in range" + +Too many servers running. Check active servers: + +```bash +cat ~/.dev-browser/active-servers.json +``` + +Clean up stale entries (servers that crashed): + +```bash +rm ~/.dev-browser/active-servers.json +``` + +### Port Conflicts + +If a specific port is required, set `PORT` environment variable: + +```bash +PORT=9250 npx tsx scripts/start-external-browser.ts +``` + +### Checking Server Status + +```bash +# List all active servers +cat ~/.dev-browser/active-servers.json + +# Test a specific server +curl http://localhost:9222/ +# Returns: {"wsEndpoint":"ws://...","mode":"external-browser","port":9222} +``` + +## References + +- [PR #15: Multi-port support discussion](https://github.com/SawyerHood/dev-browser/pull/15) +- [PR #20: External browser mode](https://github.com/SawyerHood/dev-browser/pull/20) diff --git a/skills/dev-browser/package-lock.json b/skills/dev-browser/package-lock.json index 6e4aaa4..d4ca225 100644 --- a/skills/dev-browser/package-lock.json +++ b/skills/dev-browser/package-lock.json @@ -16,6 +16,7 @@ }, "devDependencies": { "@types/express": "^5.0.0", + "esbuild": "^0.24.2", "tsx": "^4.21.0", "typescript": "^5.0.0", "vitest": "^2.1.0" @@ -25,9 +26,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", - "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", + "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", "cpu": [ "ppc64" ], @@ -42,9 +43,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", - "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", + "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", "cpu": [ "arm" ], @@ -59,9 +60,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", - "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", + "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", "cpu": [ "arm64" ], @@ -76,9 +77,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", - "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", + "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", "cpu": [ "x64" ], @@ -93,9 +94,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", - "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", + "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", "cpu": [ "arm64" ], @@ -110,9 +111,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", - "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", + "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", "cpu": [ "x64" ], @@ -127,9 +128,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", - "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", + "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", "cpu": [ "arm64" ], @@ -144,9 +145,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", - "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", + "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", "cpu": [ "x64" ], @@ -161,9 +162,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", - "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", + "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", "cpu": [ "arm" ], @@ -178,9 +179,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", - "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", + "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", "cpu": [ "arm64" ], @@ -195,9 +196,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", - "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", + "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", "cpu": [ "ia32" ], @@ -212,9 +213,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", - "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", + "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", "cpu": [ "loong64" ], @@ -229,9 +230,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", - "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", + "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", "cpu": [ "mips64el" ], @@ -246,9 +247,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", - "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", + "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", "cpu": [ "ppc64" ], @@ -263,9 +264,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", - "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", + "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", "cpu": [ "riscv64" ], @@ -280,9 +281,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", - "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", + "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", "cpu": [ "s390x" ], @@ -297,9 +298,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", - "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", "cpu": [ "x64" ], @@ -314,9 +315,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", - "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", + "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", "cpu": [ "arm64" ], @@ -331,9 +332,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", - "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", "cpu": [ "x64" ], @@ -348,9 +349,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", - "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", "cpu": [ "arm64" ], @@ -365,9 +366,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", - "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", "cpu": [ "x64" ], @@ -399,9 +400,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", - "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", "cpu": [ "x64" ], @@ -416,9 +417,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", - "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", "cpu": [ "arm64" ], @@ -433,9 +434,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", - "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", "cpu": [ "ia32" ], @@ -450,9 +451,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", - "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", "cpu": [ "x64" ], @@ -471,6 +472,7 @@ "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz", "integrity": "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.14.1" }, @@ -1295,9 +1297,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", + "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1308,32 +1310,31 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" + "@esbuild/aix-ppc64": "0.24.2", + "@esbuild/android-arm": "0.24.2", + "@esbuild/android-arm64": "0.24.2", + "@esbuild/android-x64": "0.24.2", + "@esbuild/darwin-arm64": "0.24.2", + "@esbuild/darwin-x64": "0.24.2", + "@esbuild/freebsd-arm64": "0.24.2", + "@esbuild/freebsd-x64": "0.24.2", + "@esbuild/linux-arm": "0.24.2", + "@esbuild/linux-arm64": "0.24.2", + "@esbuild/linux-ia32": "0.24.2", + "@esbuild/linux-loong64": "0.24.2", + "@esbuild/linux-mips64el": "0.24.2", + "@esbuild/linux-ppc64": "0.24.2", + "@esbuild/linux-riscv64": "0.24.2", + "@esbuild/linux-s390x": "0.24.2", + "@esbuild/linux-x64": "0.24.2", + "@esbuild/netbsd-arm64": "0.24.2", + "@esbuild/netbsd-x64": "0.24.2", + "@esbuild/openbsd-arm64": "0.24.2", + "@esbuild/openbsd-x64": "0.24.2", + "@esbuild/sunos-x64": "0.24.2", + "@esbuild/win32-arm64": "0.24.2", + "@esbuild/win32-ia32": "0.24.2", + "@esbuild/win32-x64": "0.24.2" } }, "node_modules/escape-html": { @@ -1567,6 +1568,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.1.tgz", "integrity": "sha512-KsFcH0xxHes0J4zaQgWbYwmz3UPOOskdqZmItstUG93+Wk1ePBLkLGwbP9zlmh1BFUiL8Qp+Xfu9P7feJWpGNg==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -2226,6 +2228,473 @@ "fsevents": "~2.3.3" } }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, "node_modules/tsx/node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2308,6 +2777,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/skills/dev-browser/package.json b/skills/dev-browser/package.json index 115869c..9c14284 100644 --- a/skills/dev-browser/package.json +++ b/skills/dev-browser/package.json @@ -6,8 +6,11 @@ "@/*": "./src/*" }, "scripts": { - "start-server": "npx tsx scripts/start-server.ts", - "start-extension": "npx tsx scripts/start-relay.ts", + "build": "esbuild scripts/start-server.ts scripts/start-relay.ts scripts/start-external-browser.ts --bundle --platform=node --format=esm --outdir=dist --external:playwright --external:express --external:hono --external:@hono/*", + "start-server": "node dist/start-server.js", + "start-server:dev": "npx tsx scripts/start-server.ts", + "start-extension": "node dist/start-relay.js", + "start-extension:dev": "npx tsx scripts/start-relay.ts", "dev": "npx tsx --watch src/index.ts", "test": "vitest run", "test:watch": "vitest" @@ -21,6 +24,7 @@ }, "devDependencies": { "@types/express": "^5.0.0", + "esbuild": "^0.24.2", "tsx": "^4.21.0", "typescript": "^5.0.0", "vitest": "^2.1.0" diff --git a/skills/dev-browser/references/scraping.md b/skills/dev-browser/references/scraping.md index a6e9b3c..91fc585 100644 --- a/skills/dev-browser/references/scraping.md +++ b/skills/dev-browser/references/scraping.md @@ -1,5 +1,7 @@ # Data Scraping Guide +> **Note**: This guide uses the advanced Playwright client (`client.ts`) which provides access to request/response interception. For most browser automation tasks, use the lightweight `client-lite.ts` instead (see SKILL.md). Only use the Playwright client when you specifically need request interception for scraping. + For large datasets (followers, posts, search results), **intercept and replay network requests** rather than scrolling and parsing the DOM. This is faster, more reliable, and handles pagination automatically. ## Why Not Scroll? diff --git a/skills/dev-browser/scripts/benchmark.ts b/skills/dev-browser/scripts/benchmark.ts new file mode 100644 index 0000000..2bec8ab --- /dev/null +++ b/skills/dev-browser/scripts/benchmark.ts @@ -0,0 +1,221 @@ +#!/usr/bin/env npx tsx +/** + * Performance benchmark script for dev-browser + * + * Run before and after optimizations to measure impact: + * npx tsx scripts/benchmark.ts + * + * Requires Chrome for Testing running on CDP port 9223 (or 9222) + */ + +import { performance } from "perf_hooks"; + +const CDP_PORT = process.env.CDP_PORT ? parseInt(process.env.CDP_PORT) : 9222; +const ITERATIONS = 5; + +interface BenchmarkResult { + name: string; + avgMs: number; + minMs: number; + maxMs: number; + samples: number[]; +} + +async function benchmark(name: string, fn: () => Promise, iterations = ITERATIONS): Promise { + const samples: number[] = []; + + // Warm-up run + await fn(); + + for (let i = 0; i < iterations; i++) { + const start = performance.now(); + await fn(); + samples.push(performance.now() - start); + } + + return { + name, + avgMs: samples.reduce((a, b) => a + b, 0) / samples.length, + minMs: Math.min(...samples), + maxMs: Math.max(...samples), + samples, + }; +} + +function formatResult(r: BenchmarkResult): string { + return `${r.name}: ${r.avgMs.toFixed(1)}ms avg (${r.minMs.toFixed(1)}-${r.maxMs.toFixed(1)}ms)`; +} + +// Helper to get last result (benchmark script, so we know array is populated) +function lastResult(arr: BenchmarkResult[]): BenchmarkResult { + const last = arr[arr.length - 1]; + if (!last) throw new Error("No results"); + return last; +} + +async function main() { + console.log("=== Dev-Browser Performance Benchmark ===\n"); + console.log(`CDP Port: ${CDP_PORT}`); + console.log(`Iterations: ${ITERATIONS}`); + console.log(""); + + // Check if Chrome is running + try { + const res = await fetch(`http://127.0.0.1:${CDP_PORT}/json/version`); + if (!res.ok) throw new Error("Chrome not responding"); + const info = await res.json() as { Browser: string }; + console.log(`Chrome: ${info.Browser}\n`); + } catch { + console.error(`ERROR: Chrome not running on port ${CDP_PORT}`); + console.error("Start Chrome for Testing first, or set CDP_PORT env var"); + process.exit(1); + } + + const results: BenchmarkResult[] = []; + + // Benchmark 1: Import time + console.log("--- Import Benchmarks ---"); + + results.push(await benchmark("Import playwright", async () => { + // Dynamic import to measure fresh load time + const mod = await import("playwright"); + // Force module to be used to prevent optimization + if (!mod.chromium) throw new Error("No chromium"); + }, 3)); + console.log(formatResult(lastResult(results))); + + results.push(await benchmark("Import express", async () => { + const mod = await import("express"); + if (!mod.default) throw new Error("No express"); + }, 3)); + console.log(formatResult(lastResult(results))); + + // Benchmark 2: CDP connection + console.log("\n--- Connection Benchmarks ---"); + + const { chromium } = await import("playwright"); + + results.push(await benchmark("Connect to CDP", async () => { + const browser = await chromium.connectOverCDP(`http://127.0.0.1:${CDP_PORT}`); + await browser.close(); + })); + console.log(formatResult(lastResult(results))); + + // Benchmark 3: Page operations (with persistent connection) + console.log("\n--- Page Operation Benchmarks ---"); + + const browser = await chromium.connectOverCDP(`http://127.0.0.1:${CDP_PORT}`); + const context = browser.contexts()[0] || await browser.newContext(); + + results.push(await benchmark("Create page", async () => { + const page = await context.newPage(); + await page.close(); + })); + console.log(formatResult(lastResult(results))); + + results.push(await benchmark("Create page + get targetId", async () => { + const page = await context.newPage(); + const session = await context.newCDPSession(page); + await session.send("Target.getTargetInfo"); + await session.detach(); + await page.close(); + })); + console.log(formatResult(lastResult(results))); + + const testPage = await context.newPage(); + await testPage.goto("about:blank"); + + results.push(await benchmark("page.evaluate (simple)", async () => { + await testPage.evaluate(() => 1 + 1); + }, 20)); + console.log(formatResult(lastResult(results))); + + results.push(await benchmark("page.evaluate (DOM access)", async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await testPage.evaluate(() => (globalThis as any).document.title); + }, 20)); + console.log(formatResult(lastResult(results))); + + // Benchmark 4: Page lookup simulation + console.log("\n--- Page Lookup Benchmarks ---"); + + // Create 10 pages to simulate realistic scenario + const pages: Array<{ page: Awaited>; targetId: string }> = []; + for (let i = 0; i < 10; i++) { + const page = await context.newPage(); + const session = await context.newCDPSession(page); + const { targetInfo } = await session.send("Target.getTargetInfo") as { targetInfo: { targetId: string } }; + await session.detach(); + pages.push({ page, targetId: targetInfo.targetId }); + } + + const lastPage = pages[pages.length - 1]; + if (!lastPage) throw new Error("No pages created"); + const targetToFind = lastPage.targetId; // Worst case - last page + + results.push(await benchmark("Find page (current: CDP per page)", async () => { + for (const ctx of browser.contexts()) { + for (const p of ctx.pages()) { + const session = await ctx.newCDPSession(p); + const { targetInfo } = await session.send("Target.getTargetInfo") as { targetInfo: { targetId: string } }; + await session.detach(); + if (targetInfo.targetId === targetToFind) return; + } + } + })); + console.log(formatResult(lastResult(results))); + + // Optimized: Map lookup + const pageMap = new Map(pages.map(p => [p.targetId, p.page])); + + results.push(await benchmark("Find page (optimized: Map)", async () => { + const found = pageMap.get(targetToFind); + if (!found) throw new Error("Not found"); + }, 100)); + console.log(formatResult(lastResult(results))); + + // Benchmark 5: Concurrent operations + console.log("\n--- Concurrency Benchmarks ---"); + + results.push(await benchmark("5 concurrent pages", async () => { + const newPages = await Promise.all( + Array(5).fill(0).map(() => context.newPage()) + ); + await Promise.all(newPages.map(p => p.close())); + }, 3)); + console.log(formatResult(lastResult(results))); + + // Cleanup + for (const { page } of pages) { + await page.close(); + } + await testPage.close(); + await browser.close(); + + // Summary + console.log("\n=== SUMMARY ==="); + console.log("Copy this for before/after comparison:\n"); + console.log("```"); + for (const r of results) { + console.log(`${r.name.padEnd(40)} ${r.avgMs.toFixed(1).padStart(8)}ms`); + } + console.log("```"); + + // Output as JSON for automated comparison + const jsonOutput = { + timestamp: new Date().toISOString(), + cdpPort: CDP_PORT, + iterations: ITERATIONS, + results: results.map(r => ({ + name: r.name, + avgMs: Math.round(r.avgMs * 10) / 10, + minMs: Math.round(r.minMs * 10) / 10, + maxMs: Math.round(r.maxMs * 10) / 10, + })), + }; + + console.log("\nJSON (for automated comparison):"); + console.log(JSON.stringify(jsonOutput, null, 2)); +} + +main().catch(console.error); diff --git a/skills/dev-browser/scripts/get-browser-config.ts b/skills/dev-browser/scripts/get-browser-config.ts new file mode 100644 index 0000000..998d93b --- /dev/null +++ b/skills/dev-browser/scripts/get-browser-config.ts @@ -0,0 +1,34 @@ +/** + * Output resolved browser configuration for shell scripts. + * + * Usage: npx tsx scripts/get-browser-config.ts + * + * Output format (shell-eval compatible): + * BROWSER_MODE="external" + * BROWSER_PATH="/path/to/chrome" + * BROWSER_USER_DATA_DIR="/path/to/profile" + */ + +import { getResolvedBrowserConfig } from "@/config.js"; + +/** + * Shell-escape a string value for safe eval. + */ +function shellEscape(value: string): string { + // Use double quotes and escape special characters + return `"${value.replace(/"/g, '\\"')}"`; +} + +try { + const config = getResolvedBrowserConfig(); + + // Output in shell-eval format with proper quoting + console.log(`BROWSER_MODE=${shellEscape(config.mode)}`); + console.log(`BROWSER_PATH=${shellEscape(config.path || "")}`); + // Only output userDataDir if explicitly configured + console.log(`BROWSER_USER_DATA_DIR=${shellEscape(config.userDataDir || "")}`); +} catch (err) { + // On error, fail with clear message (don't fall back to standalone) + console.error(`Error: ${err instanceof Error ? err.message : err}`); + process.exit(1); +} diff --git a/skills/dev-browser/scripts/memory-benchmark.ts b/skills/dev-browser/scripts/memory-benchmark.ts new file mode 100644 index 0000000..fb460d9 --- /dev/null +++ b/skills/dev-browser/scripts/memory-benchmark.ts @@ -0,0 +1,102 @@ +#!/usr/bin/env npx tsx +/** + * Memory benchmark: Compare Playwright client vs HTTP-only client + * + * Measures heap memory usage for: + * 1. Baseline (no imports) + * 2. client-lite (HTTP-only, no Playwright) + * 3. Full Playwright import + * + * Run: npx tsx scripts/memory-benchmark.ts + */ + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function getHeapUsed(): number { + global.gc?.(); // Force GC if available + return process.memoryUsage().heapUsed; +} + +async function measureImport(name: string, importFn: () => Promise): Promise { + global.gc?.(); + const before = getHeapUsed(); + await importFn(); + global.gc?.(); + const after = getHeapUsed(); + return after - before; +} + +async function main() { + console.log("=== Memory Benchmark: client-lite vs Playwright ===\n"); + + // Check if GC is exposed + if (!global.gc) { + console.log("Note: Run with --expose-gc for accurate measurements"); + console.log("Example: node --expose-gc --import tsx scripts/memory-benchmark.ts\n"); + } + + const baseline = getHeapUsed(); + console.log(`Baseline heap: ${formatBytes(baseline)}\n`); + + // Measure client-lite import (HTTP-only) + console.log("1. Importing client-lite (HTTP-only)..."); + const clientLiteMemory = await measureImport("client-lite", async () => { + const { connectLite } = await import("../src/client-lite.js"); + // Create client instance to ensure full initialization + const client = await connectLite("http://localhost:9222"); + return client; + }); + console.log(` Memory added: ${formatBytes(clientLiteMemory)}`); + + // Force new process measurement for Playwright to avoid module caching effects + console.log("\n2. Importing Playwright (full client)..."); + const playwrightMemory = await measureImport("playwright", async () => { + const { chromium } = await import("playwright"); + return chromium; + }); + console.log(` Memory added: ${formatBytes(playwrightMemory)}`); + + // Calculate savings + console.log("\n=== Results ==="); + console.log(`client-lite memory: ${formatBytes(clientLiteMemory)}`); + console.log(`Playwright memory: ${formatBytes(playwrightMemory)}`); + + if (playwrightMemory > clientLiteMemory) { + const savings = playwrightMemory - clientLiteMemory; + const percentage = ((savings / playwrightMemory) * 100).toFixed(1); + console.log(`\nSavings: ${formatBytes(savings)} (${percentage}% reduction)`); + } + + // Also measure full client.ts import for comparison + console.log("\n3. Importing full client.ts (with Playwright)..."); + const fullClientMemory = await measureImport("client", async () => { + const { connect } = await import("../src/client.js"); + return connect; + }); + console.log(` Memory added: ${formatBytes(fullClientMemory)}`); + + console.log("\n=== Summary ==="); + console.log("┌─────────────────────┬──────────────┐"); + console.log("│ Import │ Memory │"); + console.log("├─────────────────────┼──────────────┤"); + console.log(`│ client-lite │ ${formatBytes(clientLiteMemory).padStart(12)} │`); + console.log(`│ Playwright only │ ${formatBytes(playwrightMemory).padStart(12)} │`); + console.log(`│ Full client.ts │ ${formatBytes(fullClientMemory).padStart(12)} │`); + console.log("└─────────────────────┴──────────────┘"); + + // Per-agent impact + console.log("\n=== Per-Agent Impact (10 agents) ==="); + const agents = 10; + console.log(`Full client (current): ${formatBytes(fullClientMemory * agents)} total`); + console.log(`client-lite (new): ${formatBytes(clientLiteMemory * agents)} total`); + if (fullClientMemory > clientLiteMemory) { + const totalSavings = (fullClientMemory - clientLiteMemory) * agents; + console.log(`Savings with 10 agents: ${formatBytes(totalSavings)}`); + } +} + +main().catch(console.error); diff --git a/skills/dev-browser/scripts/start-external-browser.ts b/skills/dev-browser/scripts/start-external-browser.ts new file mode 100644 index 0000000..1d41e7c --- /dev/null +++ b/skills/dev-browser/scripts/start-external-browser.ts @@ -0,0 +1,89 @@ +/** + * Start dev-browser server connecting to an external browser via CDP. + * + * This mode is ideal for: + * - Chrome for Testing or other specific browser builds + * - Development workflows where you want the browser visible + * - Keeping the browser open after automation for manual inspection + * - Running multiple agents concurrently (each gets its own port automatically) + * + * Environment variables: + * PORT - HTTP API port (default: auto-assigned from 9222-9300) + * CDP_PORT - Browser's CDP port (default: 9223) + * BROWSER_PATH - Path to browser executable (for auto-launch) + * USER_DATA_DIR - Browser profile directory (default: ~/.dev-browser-profile) + * AUTO_LAUNCH - Whether to auto-launch browser if not running (default: true) + * + * Configuration file: ~/.dev-browser/config.json + * { + * "portRange": { "start": 9222, "end": 9300, "step": 2 }, + * "cdpPort": 9223 + * } + * + * Example with Chrome for Testing: + * BROWSER_PATH="/Applications/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing" \ + * npx tsx scripts/start-external-browser.ts + * + * Multi-agent usage: + * # Terminal 1: First agent gets port 9222 + * npx tsx scripts/start-external-browser.ts + * # Output: PORT=9222 + * + * # Terminal 2: Second agent gets port 9224 + * npx tsx scripts/start-external-browser.ts + * # Output: PORT=9224 + * + * # Both agents share the same browser on CDP port 9223 + */ + +import { serveWithExternalBrowser } from "@/external-browser.js"; +import { mkdirSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const tmpDir = join(__dirname, "..", "tmp"); + +// Create tmp directory if it doesn't exist +mkdirSync(tmpDir, { recursive: true }); + +// Configuration from environment (PORT is optional - will be auto-assigned) +const port = process.env.PORT ? parseInt(process.env.PORT, 10) : undefined; +const cdpPort = process.env.CDP_PORT ? parseInt(process.env.CDP_PORT, 10) : undefined; +const browserPath = process.env.BROWSER_PATH; +// Only pass userDataDir if explicitly set - let browser use default profile otherwise +const userDataDir = process.env.USER_DATA_DIR || undefined; +const autoLaunch = process.env.AUTO_LAUNCH !== "false"; + +console.log("Starting dev-browser with external browser mode..."); +console.log(` HTTP API port: ${port ?? "auto (dynamic)"}`); +console.log(` CDP port: ${cdpPort ?? "from config (default: 9223)"}`); +if (browserPath) { + console.log(` Browser path: ${browserPath}`); +} +console.log(` User data dir: ${userDataDir ?? "(default profile)"}`); +console.log(` Auto-launch: ${autoLaunch}`); +console.log(` Config: ~/.dev-browser/config.json`); +console.log(""); + +const server = await serveWithExternalBrowser({ + port, + cdpPort, + browserPath, + userDataDir, + autoLaunch, +}); + +console.log(""); +console.log(`Dev browser server started`); +console.log(` WebSocket: ${server.wsEndpoint}`); +console.log(` HTTP API: http://localhost:${server.port}`); +console.log(` Mode: ${server.mode}`); +console.log(` Tmp directory: ${tmpDir}`); +console.log(""); +console.log("Ready"); +console.log(""); +console.log("Press Ctrl+C to stop (browser will remain open)"); + +// Keep the process running +await new Promise(() => {}); diff --git a/skills/dev-browser/scripts/start-server.ts b/skills/dev-browser/scripts/start-server.ts index e130a27..ccc2135 100644 --- a/skills/dev-browser/scripts/start-server.ts +++ b/skills/dev-browser/scripts/start-server.ts @@ -1,3 +1,31 @@ +/** + * Start dev-browser server in standalone mode (launches Playwright Chromium). + * + * This mode: + * - Launches a dedicated Playwright Chromium browser + * - Owns the browser lifecycle (closes when server stops) + * - Supports multiple concurrent agents via dynamic port allocation + * + * Environment variables: + * PORT - HTTP API port (default: auto-assigned from 9222-9300) + * HEADLESS - Run browser in headless mode (default: false) + * + * Configuration file: ~/.dev-browser/config.json + * { + * "portRange": { "start": 9222, "end": 9300, "step": 2 }, + * "cdpPort": 9223 + * } + * + * Multi-agent usage: + * # Terminal 1: First agent gets port 9222, launches browser + * npx tsx scripts/start-server.ts + * # Output: PORT=9222 + * + * # Terminal 2: Second agent gets port 9224, launches separate browser + * npx tsx scripts/start-server.ts + * # Output: PORT=9224 + */ + import { serve } from "@/index.js"; import { execSync } from "child_process"; import { mkdirSync, existsSync, readdirSync } from "fs"; @@ -9,9 +37,7 @@ const tmpDir = join(__dirname, "..", "tmp"); const profileDir = join(__dirname, "..", "profiles"); // Create tmp and profile directories if they don't exist -console.log("Creating tmp directory..."); mkdirSync(tmpDir, { recursive: true }); -console.log("Creating profiles directory..."); mkdirSync(profileDir, { recursive: true }); // Install Playwright browsers if not already installed @@ -72,46 +98,33 @@ try { console.log("You may need to run: npx playwright install chromium"); } -// Check if server is already running -console.log("Checking for existing servers..."); -try { - const res = await fetch("http://localhost:9222", { - signal: AbortSignal.timeout(1000), - }); - if (res.ok) { - console.log("Server already running on port 9222"); - process.exit(0); - } -} catch { - // Server not running, continue to start -} +// Configuration from environment (PORT is optional - will be auto-assigned) +const port = process.env.PORT ? parseInt(process.env.PORT, 10) : undefined; +const headless = process.env.HEADLESS === "true"; -// Clean up stale CDP port if HTTP server isn't running (crash recovery) -// This handles the case where Node crashed but Chrome is still running on 9223 -try { - const pid = execSync("lsof -ti:9223", { encoding: "utf-8" }).trim(); - if (pid) { - console.log(`Cleaning up stale Chrome process on CDP port 9223 (PID: ${pid})`); - execSync(`kill -9 ${pid}`); - } -} catch { - // No process on CDP port, which is expected -} +console.log(""); +console.log("Starting dev browser server (standalone mode)..."); +console.log(` HTTP API port: ${port ?? "auto (dynamic)"}`); +console.log(` Headless: ${headless}`); +console.log(` Config: ~/.dev-browser/config.json`); +console.log(""); -console.log("Starting dev browser server..."); -const headless = process.env.HEADLESS === "true"; const server = await serve({ - port: 9222, + port, headless, profileDir, }); +console.log(""); console.log(`Dev browser server started`); console.log(` WebSocket: ${server.wsEndpoint}`); +console.log(` HTTP API: http://localhost:${server.port}`); console.log(` Tmp directory: ${tmpDir}`); console.log(` Profile directory: ${profileDir}`); -console.log(`\nReady`); -console.log(`\nPress Ctrl+C to stop`); +console.log(""); +console.log("Ready"); +console.log(""); +console.log("Press Ctrl+C to stop"); // Keep the process running await new Promise(() => {}); diff --git a/skills/dev-browser/scripts/test-http-api.ts b/skills/dev-browser/scripts/test-http-api.ts new file mode 100644 index 0000000..5a043de --- /dev/null +++ b/skills/dev-browser/scripts/test-http-api.ts @@ -0,0 +1,111 @@ +#!/usr/bin/env npx tsx +/** + * Test script for HTTP-only API endpoints (Phase 2) + * + * Tests the new server endpoints that enable the lightweight client. + * Requires a dev-browser server running on port 9222. + */ + +const SERVER_URL = process.env.SERVER_URL || "http://localhost:9222"; + +async function jsonRequest(path: string, options?: RequestInit): Promise { + const res = await fetch(`${SERVER_URL}${path}`, { + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }); + + const text = await res.text(); + if (!res.ok) { + throw new Error(`HTTP ${res.status}: ${text}`); + } + + return JSON.parse(text) as T; +} + +async function main() { + console.log("=== Testing HTTP-only API endpoints ===\n"); + console.log(`Server: ${SERVER_URL}`); + + // Check server is running + try { + const info = await jsonRequest<{ wsEndpoint: string; mode?: string }>("/"); + console.log(`Server mode: ${info.mode || "unknown"}`); + console.log(`WebSocket endpoint: ${info.wsEndpoint}\n`); + } catch (err) { + console.error("ERROR: Server not running or not reachable"); + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + + const pageName = "test-http-api"; + + try { + // 1. Create page + console.log("1. Creating page..."); + const pageInfo = await jsonRequest<{ name: string; targetId: string }>("/pages", { + method: "POST", + body: JSON.stringify({ name: pageName }), + }); + console.log(` Created page: ${pageInfo.name} (targetId: ${pageInfo.targetId.slice(0, 8)}...)`); + + // 2. Navigate + console.log("2. Navigating to example.com..."); + const navResult = await jsonRequest<{ url: string; title: string }>(`/pages/${pageName}/navigate`, { + method: "POST", + body: JSON.stringify({ url: "https://example.com" }), + }); + console.log(` URL: ${navResult.url}`); + console.log(` Title: ${navResult.title}`); + + // 3. Evaluate + console.log("3. Evaluating JavaScript..."); + const evalResult = await jsonRequest<{ result: unknown }>(`/pages/${pageName}/evaluate`, { + method: "POST", + body: JSON.stringify({ expression: "document.title" }), + }); + console.log(` Result: ${evalResult.result}`); + + // 4. Get snapshot + console.log("4. Getting AI snapshot..."); + const snapshotResult = await jsonRequest<{ snapshot: string }>(`/pages/${pageName}/snapshot`); + const snapshotLines = snapshotResult.snapshot.split("\n"); + console.log(` Snapshot lines: ${snapshotLines.length}`); + console.log(` First 3 lines:`); + snapshotLines.slice(0, 3).forEach(line => console.log(` ${line}`)); + + // 5. Select ref (find a link) + console.log("5. Selecting ref from snapshot..."); + const linkMatch = snapshotResult.snapshot.match(/\[ref=(e\d+)\]/); + if (linkMatch) { + const ref = linkMatch[1]; + const refResult = await jsonRequest<{ found: boolean; tagName?: string }>(`/pages/${pageName}/select-ref`, { + method: "POST", + body: JSON.stringify({ ref }), + }); + console.log(` Ref ${ref}: found=${refResult.found}, tag=${refResult.tagName}`); + } else { + console.log(" No refs found in snapshot"); + } + + // 6. Clean up + console.log("6. Closing page..."); + await jsonRequest(`/pages/${pageName}`, { method: "DELETE" }); + console.log(" Page closed"); + + console.log("\n=== All tests passed! ==="); + } catch (err) { + console.error("\nERROR:", err instanceof Error ? err.message : String(err)); + // Try to clean up + try { + await jsonRequest(`/pages/${pageName}`, { method: "DELETE" }); + } catch { + // Ignore cleanup errors + } + process.exit(1); + } +} + +main(); diff --git a/skills/dev-browser/server.sh b/skills/dev-browser/server.sh index 50369a4..238295f 100755 --- a/skills/dev-browser/server.sh +++ b/skills/dev-browser/server.sh @@ -8,17 +8,103 @@ cd "$SCRIPT_DIR" # Parse command line arguments HEADLESS=false +FORCE_STANDALONE=false while [[ "$#" -gt 0 ]]; do case $1 in --headless) HEADLESS=true ;; + --standalone) FORCE_STANDALONE=true ;; *) echo "Unknown parameter: $1"; exit 1 ;; esac shift done -echo "Installing dependencies..." -npm install +# Conditional npm install - only if node_modules missing or package-lock changed +NEEDS_INSTALL=false +HASH_FILE="$SCRIPT_DIR/.npm-install-hash" -echo "Starting dev-browser server..." -export HEADLESS=$HEADLESS -npx tsx scripts/start-server.ts +if [ ! -d "$SCRIPT_DIR/node_modules" ]; then + NEEDS_INSTALL=true +elif [ -f "$SCRIPT_DIR/package-lock.json" ]; then + CURRENT_HASH=$(shasum "$SCRIPT_DIR/package-lock.json" 2>/dev/null | cut -d' ' -f1) + SAVED_HASH=$(cat "$HASH_FILE" 2>/dev/null || echo "") + if [ "$CURRENT_HASH" != "$SAVED_HASH" ]; then + NEEDS_INSTALL=true + fi +fi + +if [ "$NEEDS_INSTALL" = true ]; then + echo "Installing dependencies..." + npm install --prefer-offline --no-audit --no-fund + # Save hash for next time + if [ -f "$SCRIPT_DIR/package-lock.json" ]; then + shasum "$SCRIPT_DIR/package-lock.json" | cut -d' ' -f1 > "$HASH_FILE" + fi +else + echo "Dependencies up to date (skipping npm install)" +fi + +# Build if dist doesn't exist (first run optimization) +if [ ! -f "$SCRIPT_DIR/dist/start-server.js" ]; then + echo "Building TypeScript (first run)..." + npm run build +fi + +# Get browser configuration from config file +# Config is at ~/.dev-browser/config.json +if [ "$FORCE_STANDALONE" = true ]; then + BROWSER_MODE="standalone" + BROWSER_PATH="" +else + # Read config using TypeScript helper + CONFIG_OUTPUT=$(npx tsx scripts/get-browser-config.ts 2>&1) + CONFIG_EXIT=$? + if [ $CONFIG_EXIT -eq 0 ]; then + eval "$CONFIG_OUTPUT" + else + # Config read failed - show error and exit (don't fall back to standalone) + echo "Error: Failed to read browser configuration" + echo "$CONFIG_OUTPUT" + echo "" + echo "Set browser.path in ~/.dev-browser/config.json to your Chrome executable or app bundle." + exit 1 + fi +fi + +# Start the appropriate server mode +if [ "$BROWSER_MODE" = "external" ] && [ -n "$BROWSER_PATH" ]; then + echo "Starting dev-browser server (External Browser mode)..." + echo " Browser: $BROWSER_PATH" + echo " Config: ~/.dev-browser/config.json" + echo " Use --standalone flag to force standalone Playwright mode" + echo "" + + export BROWSER_PATH + # Only export USER_DATA_DIR if explicitly configured (not empty) + if [ -n "$BROWSER_USER_DATA_DIR" ]; then + export USER_DATA_DIR="$BROWSER_USER_DATA_DIR" + fi + npx tsx scripts/start-external-browser.ts +else + # Only reach here if --standalone was explicitly passed + if [ "$FORCE_STANDALONE" = true ]; then + echo "Starting dev-browser server (Standalone mode - forced)..." + echo " WARNING: Using Playwright's bundled Chromium, not Chrome for Testing" + echo " For consistent behavior, use Chrome for Testing instead" + echo "" + + export HEADLESS=$HEADLESS + # Use pre-compiled JS for faster startup (~700ms savings) + if [ -f "$SCRIPT_DIR/dist/start-server.js" ]; then + node "$SCRIPT_DIR/dist/start-server.js" + else + # Fallback to tsx if build failed + npx tsx scripts/start-server.ts + fi + else + # Should not reach here - config should have failed earlier + echo "Error: No browser configured and standalone mode not forced" + echo "" + echo "Set browser.path in ~/.dev-browser/config.json to your Chrome executable or app bundle." + exit 1 + fi +fi diff --git a/skills/dev-browser/src/__tests__/client-lite.test.ts b/skills/dev-browser/src/__tests__/client-lite.test.ts new file mode 100644 index 0000000..9cea29c --- /dev/null +++ b/skills/dev-browser/src/__tests__/client-lite.test.ts @@ -0,0 +1,401 @@ +/** + * Client-lite tests + * + * Tests the lightweight HTTP-only client. + * Mocks fetch to test client logic without requiring a running server. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { connectLite, type DevBrowserLiteClient } from "../client-lite"; +import type { + GetPageResponse, + ListPagesResponse, + NavigateResponse, + EvaluateResponse, + SnapshotResponse, + SelectRefResponse, +} from "../types"; + +// Mock fetch globally +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +function mockJsonResponse(data: T, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(data), + text: () => Promise.resolve(JSON.stringify(data)), + } as Response; +} + +function mockErrorResponse(message: string, status = 500): Response { + return { + ok: false, + status, + json: () => Promise.resolve({ error: message }), + text: () => Promise.resolve(message), + } as Response; +} + +describe("connectLite", () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("should return client interface", async () => { + const client = await connectLite("http://localhost:9222"); + + expect(client.page).toBeTypeOf("function"); + expect(client.list).toBeTypeOf("function"); + expect(client.close).toBeTypeOf("function"); + expect(client.navigate).toBeTypeOf("function"); + expect(client.evaluate).toBeTypeOf("function"); + expect(client.getAISnapshot).toBeTypeOf("function"); + expect(client.selectRef).toBeTypeOf("function"); + expect(client.click).toBeTypeOf("function"); + expect(client.fill).toBeTypeOf("function"); + expect(client.getServerInfo).toBeTypeOf("function"); + expect(client.disconnect).toBeTypeOf("function"); + }); + + it("should use default server URL", async () => { + const client = await connectLite(); + mockFetch.mockResolvedValueOnce( + mockJsonResponse({ pages: [] }) + ); + + await client.list(); + + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:9222/pages", + expect.any(Object) + ); + }); + + it("should use custom server URL", async () => { + const client = await connectLite("http://localhost:9333"); + mockFetch.mockResolvedValueOnce( + mockJsonResponse({ pages: [] }) + ); + + await client.list(); + + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:9333/pages", + expect.any(Object) + ); + }); +}); + +describe("DevBrowserLiteClient", () => { + let client: DevBrowserLiteClient; + + beforeEach(async () => { + mockFetch.mockReset(); + client = await connectLite("http://localhost:9222"); + }); + + describe("page()", () => { + it("should create page via POST /pages", async () => { + const response: GetPageResponse = { + wsEndpoint: "ws://localhost:9222", + name: "test-page", + targetId: "target-123", + mode: "launch", + }; + mockFetch.mockResolvedValueOnce(mockJsonResponse(response)); + + const result = await client.page("test-page"); + + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:9222/pages", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ name: "test-page" }), + }) + ); + expect(result.name).toBe("test-page"); + expect(result.targetId).toBe("target-123"); + }); + }); + + describe("list()", () => { + it("should list pages via GET /pages", async () => { + const response: ListPagesResponse = { pages: ["page1", "page2"] }; + mockFetch.mockResolvedValueOnce(mockJsonResponse(response)); + + const result = await client.list(); + + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:9222/pages", + expect.any(Object) + ); + expect(result).toEqual(["page1", "page2"]); + }); + }); + + describe("close()", () => { + it("should close page via DELETE /pages/:name", async () => { + mockFetch.mockResolvedValueOnce(mockJsonResponse({ success: true })); + + await client.close("test-page"); + + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:9222/pages/test-page", + expect.objectContaining({ method: "DELETE" }) + ); + }); + + it("should encode special characters in page name", async () => { + mockFetch.mockResolvedValueOnce(mockJsonResponse({ success: true })); + + await client.close("page with spaces"); + + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:9222/pages/page%20with%20spaces", + expect.any(Object) + ); + }); + }); + + describe("navigate()", () => { + it("should navigate via POST /pages/:name/navigate", async () => { + const response: NavigateResponse = { + url: "https://example.com", + title: "Example Domain", + }; + mockFetch.mockResolvedValueOnce(mockJsonResponse(response)); + + const result = await client.navigate("test-page", "https://example.com"); + + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:9222/pages/test-page/navigate", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ url: "https://example.com", waitUntil: undefined }), + }) + ); + expect(result.url).toBe("https://example.com"); + expect(result.title).toBe("Example Domain"); + }); + + it("should pass waitUntil option", async () => { + const response: NavigateResponse = { + url: "https://example.com", + title: "Example", + }; + mockFetch.mockResolvedValueOnce(mockJsonResponse(response)); + + await client.navigate("test-page", "https://example.com", "networkidle"); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: JSON.stringify({ url: "https://example.com", waitUntil: "networkidle" }), + }) + ); + }); + }); + + describe("evaluate()", () => { + it("should evaluate via POST /pages/:name/evaluate", async () => { + const response: EvaluateResponse = { result: "Example Domain" }; + mockFetch.mockResolvedValueOnce(mockJsonResponse(response)); + + const result = await client.evaluate("test-page", "document.title"); + + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:9222/pages/test-page/evaluate", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ expression: "document.title" }), + }) + ); + expect(result).toBe("Example Domain"); + }); + + it("should throw on evaluation error", async () => { + const response: EvaluateResponse = { + result: null, + error: "ReferenceError: foo is not defined", + }; + mockFetch.mockResolvedValueOnce(mockJsonResponse(response)); + + await expect(client.evaluate("test-page", "foo")).rejects.toThrow( + "ReferenceError: foo is not defined" + ); + }); + }); + + describe("getAISnapshot()", () => { + it("should get snapshot via GET /pages/:name/snapshot", async () => { + const response: SnapshotResponse = { + snapshot: "- document\n - heading [ref=e1] 'Example'", + }; + mockFetch.mockResolvedValueOnce(mockJsonResponse(response)); + + const result = await client.getAISnapshot("test-page"); + + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:9222/pages/test-page/snapshot", + expect.any(Object) + ); + expect(result).toContain("heading"); + expect(result).toContain("ref=e1"); + }); + + it("should throw on snapshot error", async () => { + const response: SnapshotResponse = { + snapshot: "", + error: "Page not loaded", + }; + mockFetch.mockResolvedValueOnce(mockJsonResponse(response)); + + await expect(client.getAISnapshot("test-page")).rejects.toThrow("Page not loaded"); + }); + }); + + describe("selectRef()", () => { + it("should select ref via POST /pages/:name/select-ref", async () => { + const response: SelectRefResponse = { + found: true, + tagName: "A", + textContent: "More information...", + }; + mockFetch.mockResolvedValueOnce(mockJsonResponse(response)); + + const result = await client.selectRef("test-page", "e123"); + + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:9222/pages/test-page/select-ref", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ ref: "e123" }), + }) + ); + expect(result.found).toBe(true); + expect(result.tagName).toBe("A"); + }); + + it("should handle ref not found", async () => { + const response: SelectRefResponse = { found: false }; + mockFetch.mockResolvedValueOnce(mockJsonResponse(response)); + + const result = await client.selectRef("test-page", "e999"); + + expect(result.found).toBe(false); + expect(result.tagName).toBeUndefined(); + }); + }); + + describe("click()", () => { + it("should click via POST /pages/:name/click", async () => { + mockFetch.mockResolvedValueOnce(mockJsonResponse({ success: true })); + + await client.click("test-page", "e123"); + + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:9222/pages/test-page/click", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ ref: "e123" }), + }) + ); + }); + + it("should throw on click error", async () => { + mockFetch.mockResolvedValueOnce( + mockJsonResponse({ error: 'Ref "e999" not found' }) + ); + + await expect(client.click("test-page", "e999")).rejects.toThrow( + 'Ref "e999" not found' + ); + }); + }); + + describe("fill()", () => { + it("should fill via POST /pages/:name/fill", async () => { + mockFetch.mockResolvedValueOnce(mockJsonResponse({ success: true })); + + await client.fill("test-page", "e123", "test value"); + + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:9222/pages/test-page/fill", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ ref: "e123", value: "test value" }), + }) + ); + }); + + it("should throw on fill error", async () => { + mockFetch.mockResolvedValueOnce( + mockJsonResponse({ error: "Element is not fillable" }) + ); + + await expect(client.fill("test-page", "e123", "value")).rejects.toThrow( + "Element is not fillable" + ); + }); + }); + + describe("getServerInfo()", () => { + it("should get server info via GET /", async () => { + mockFetch.mockResolvedValueOnce( + mockJsonResponse({ + wsEndpoint: "ws://localhost:9222", + mode: "extension", + extensionConnected: true, + }) + ); + + const result = await client.getServerInfo(); + + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:9222/", + expect.any(Object) + ); + expect(result.wsEndpoint).toBe("ws://localhost:9222"); + expect(result.mode).toBe("extension"); + expect(result.extensionConnected).toBe(true); + }); + + it("should default to launch mode", async () => { + mockFetch.mockResolvedValueOnce( + mockJsonResponse({ wsEndpoint: "ws://localhost:9222" }) + ); + + const result = await client.getServerInfo(); + + expect(result.mode).toBe("launch"); + }); + }); + + describe("disconnect()", () => { + it("should be a no-op for HTTP client", async () => { + // disconnect() should not throw and not make any HTTP requests + await expect(client.disconnect()).resolves.toBeUndefined(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + + describe("error handling", () => { + it("should throw on HTTP error response", async () => { + mockFetch.mockResolvedValueOnce(mockErrorResponse("page not found", 404)); + + await expect(client.list()).rejects.toThrow("HTTP 404"); + }); + + it("should throw on network error", async () => { + mockFetch.mockRejectedValueOnce(new Error("ECONNREFUSED")); + + await expect(client.list()).rejects.toThrow("ECONNREFUSED"); + }); + }); +}); diff --git a/skills/dev-browser/src/__tests__/http-api.test.ts b/skills/dev-browser/src/__tests__/http-api.test.ts new file mode 100644 index 0000000..aa0b2ce --- /dev/null +++ b/skills/dev-browser/src/__tests__/http-api.test.ts @@ -0,0 +1,258 @@ +/** + * HTTP API endpoint tests + * + * Tests the server-side HTTP endpoints that power client-lite. + * Uses mocked express request/response to test endpoint logic in isolation. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Request, Response } from "express"; +import type { + GetPageRequest, + GetPageResponse, + ListPagesResponse, + EvaluateRequest, + NavigateRequest, + SelectRefRequest, +} from "../types"; + +// Mock page registry for testing endpoint logic +interface MockPageEntry { + name: string; + targetId: string; + url: string; + title: string; +} + +function createMockRegistry() { + const pages = new Map(); + return { + pages, + get: (name: string) => pages.get(name), + set: (name: string, entry: MockPageEntry) => pages.set(name, entry), + delete: (name: string) => pages.delete(name), + keys: () => pages.keys(), + }; +} + +function mockResponse() { + const res = { + statusCode: 200, + body: null as unknown, + status: vi.fn((code: number) => { + res.statusCode = code; + return res; + }), + json: vi.fn((data: unknown) => { + res.body = data; + return res; + }), + }; + return res as unknown as Response & { statusCode: number; body: unknown }; +} + +describe("HTTP API Types", () => { + describe("GetPageRequest", () => { + it("should require name field", () => { + const valid: GetPageRequest = { name: "test-page" }; + expect(valid.name).toBe("test-page"); + }); + }); + + describe("GetPageResponse", () => { + it("should include all required fields", () => { + const response: GetPageResponse = { + wsEndpoint: "ws://localhost:9222", + name: "test-page", + targetId: "ABC123", + mode: "launch", + }; + expect(response.wsEndpoint).toBeDefined(); + expect(response.name).toBeDefined(); + expect(response.targetId).toBeDefined(); + expect(response.mode).toBe("launch"); + }); + + it("should support extension mode", () => { + const response: GetPageResponse = { + wsEndpoint: "ws://localhost:9222", + name: "test-page", + targetId: "ABC123", + mode: "extension", + }; + expect(response.mode).toBe("extension"); + }); + }); + + describe("ListPagesResponse", () => { + it("should return array of page names", () => { + const response: ListPagesResponse = { + pages: ["page1", "page2", "page3"], + }; + expect(response.pages).toHaveLength(3); + expect(response.pages).toContain("page1"); + }); + }); +}); + +describe("Request Validation Logic", () => { + describe("POST /pages validation", () => { + it("should reject missing name", () => { + const body = {} as GetPageRequest; + const isValid = body.name && typeof body.name === "string"; + expect(isValid).toBeFalsy(); + }); + + it("should reject non-string name", () => { + const body = { name: 123 } as unknown as GetPageRequest; + const isValid = body.name && typeof body.name === "string"; + expect(isValid).toBeFalsy(); + }); + + it("should reject empty name", () => { + const body: GetPageRequest = { name: "" }; + const isValid = body.name.length > 0; + expect(isValid).toBeFalsy(); + }); + + it("should reject name over 256 chars", () => { + const body: GetPageRequest = { name: "a".repeat(257) }; + const isValid = body.name.length <= 256; + expect(isValid).toBeFalsy(); + }); + + it("should accept valid name", () => { + const body: GetPageRequest = { name: "my-test-page" }; + const isValid = body.name && typeof body.name === "string" && body.name.length > 0 && body.name.length <= 256; + expect(isValid).toBeTruthy(); + }); + }); + + describe("POST /pages/:name/navigate validation", () => { + it("should reject missing url", () => { + const body = {} as NavigateRequest; + const isValid = !!body.url; + expect(isValid).toBeFalsy(); + }); + + it("should accept valid url with default waitUntil", () => { + const body: NavigateRequest = { url: "https://example.com" }; + expect(body.url).toBeDefined(); + expect(body.waitUntil).toBeUndefined(); + }); + + it("should accept valid waitUntil options", () => { + const options: NavigateRequest["waitUntil"][] = ["load", "domcontentloaded", "networkidle"]; + options.forEach((opt) => { + const body: NavigateRequest = { url: "https://example.com", waitUntil: opt }; + expect(body.waitUntil).toBe(opt); + }); + }); + }); + + describe("POST /pages/:name/evaluate validation", () => { + it("should reject missing expression", () => { + const body = {} as EvaluateRequest; + const isValid = !!body.expression; + expect(isValid).toBeFalsy(); + }); + + it("should accept valid expression", () => { + const body: EvaluateRequest = { expression: "document.title" }; + expect(body.expression).toBeDefined(); + }); + }); + + describe("POST /pages/:name/select-ref validation", () => { + it("should reject missing ref", () => { + const body = {} as SelectRefRequest; + const isValid = !!body.ref; + expect(isValid).toBeFalsy(); + }); + + it("should accept valid ref", () => { + const body: SelectRefRequest = { ref: "e123" }; + expect(body.ref).toBeDefined(); + }); + }); +}); + +describe("Page Registry Logic", () => { + let registry: ReturnType; + + beforeEach(() => { + registry = createMockRegistry(); + }); + + it("should create new page if not exists", () => { + const name = "new-page"; + expect(registry.get(name)).toBeUndefined(); + + registry.set(name, { + name, + targetId: "target-123", + url: "about:blank", + title: "", + }); + + expect(registry.get(name)).toBeDefined(); + expect(registry.get(name)?.targetId).toBe("target-123"); + }); + + it("should return existing page if exists", () => { + const name = "existing-page"; + registry.set(name, { + name, + targetId: "target-456", + url: "https://example.com", + title: "Example", + }); + + const entry = registry.get(name); + expect(entry?.targetId).toBe("target-456"); + }); + + it("should delete page from registry", () => { + const name = "to-delete"; + registry.set(name, { + name, + targetId: "target-789", + url: "about:blank", + title: "", + }); + + expect(registry.get(name)).toBeDefined(); + registry.delete(name); + expect(registry.get(name)).toBeUndefined(); + }); + + it("should list all page names", () => { + registry.set("page1", { name: "page1", targetId: "t1", url: "", title: "" }); + registry.set("page2", { name: "page2", targetId: "t2", url: "", title: "" }); + registry.set("page3", { name: "page3", targetId: "t3", url: "", title: "" }); + + const names = Array.from(registry.keys()); + expect(names).toHaveLength(3); + expect(names).toContain("page1"); + expect(names).toContain("page2"); + expect(names).toContain("page3"); + }); +}); + +describe("URL Encoding", () => { + it("should handle special characters in page names", () => { + const specialNames = [ + "page with spaces", + "page/with/slashes", + "page?with=query", + "page#with#hash", + "unicode-页面", + ]; + + specialNames.forEach((name) => { + const encoded = encodeURIComponent(name); + const decoded = decodeURIComponent(encoded); + expect(decoded).toBe(name); + }); + }); +}); diff --git a/skills/dev-browser/src/client-lite.ts b/skills/dev-browser/src/client-lite.ts new file mode 100644 index 0000000..6502486 --- /dev/null +++ b/skills/dev-browser/src/client-lite.ts @@ -0,0 +1,290 @@ +/** + * Lightweight HTTP-only client for dev-browser. + * + * This client uses only HTTP requests to communicate with the server, + * eliminating the need for Playwright dependency on the client side. + * All page operations (navigate, evaluate, snapshot, click, fill) are + * handled server-side via HTTP endpoints. + * + * Port Discovery: + * The client discovers the server port in this order: + * 1. DEV_BROWSER_PORT environment variable (explicit) + * 2. tmp/port file in skill directory (same session) + * 3. Most recent server from ~/.dev-browser/active-servers.json + * 4. Default port 19222 as last resort + * + * Benefits: + * - No Playwright dependency (~170MB savings per agent) + * - Simpler client implementation + * - Single CDP connection on server (shared across all clients) + * - Faster client startup (no heavy imports) + */ + +import type { + GetPageRequest, + GetPageResponse, + ListPagesResponse, + ServerInfoResponse, + EvaluateResponse, + SnapshotResponse, + NavigateResponse, + SelectRefResponse, + ScreenshotResponse, + SetViewportResponse, + WaitForSelectorResponse, + PageInfoResponse, +} from "./types"; +import { readPortFile, getMostRecentServer } from "./config.js"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SKILL_DIR = join(__dirname, ".."); +const DEFAULT_PORT = 19222; + +/** + * Discover the server port using the following priority: + * 1. DEV_BROWSER_PORT environment variable + * 2. tmp/port file in skill directory + * 3. Most recent server from active-servers.json + * 4. Default port as last resort + */ +function discoverPort(): number { + // 1. Check environment variable + const envPort = process.env.DEV_BROWSER_PORT; + if (envPort) { + const port = parseInt(envPort, 10); + if (!isNaN(port)) { + return port; + } + } + + // 2. Check tmp/port file + const filePort = readPortFile(SKILL_DIR); + if (filePort !== null) { + return filePort; + } + + // 3. Check active-servers.json for most recent server + const recentServer = getMostRecentServer(); + if (recentServer) { + return recentServer.port; + } + + // 4. Fall back to default + return DEFAULT_PORT; +} + +/** Server mode information */ +export interface ServerInfo { + wsEndpoint: string; + mode: "launch" | "extension"; + extensionConnected?: boolean; +} + +export interface DevBrowserLiteClient { + /** + * Get or create a page by name. + * Returns page info without requiring client-side CDP connection. + */ + page: (name: string) => Promise<{ name: string; targetId: string }>; + + /** List all page names */ + list: () => Promise; + + /** Close a page by name */ + close: (name: string) => Promise; + + /** Navigate a page to a URL */ + navigate: (name: string, url: string, waitUntil?: "load" | "domcontentloaded" | "networkidle") => Promise; + + /** Evaluate JavaScript on a page */ + evaluate: (name: string, expression: string) => Promise; + + /** Get AI-friendly ARIA snapshot of a page */ + getAISnapshot: (name: string) => Promise; + + /** Get element info by ref from last snapshot */ + selectRef: (name: string, ref: string) => Promise; + + /** Click on element by ref */ + click: (name: string, ref: string) => Promise; + + /** Fill input by ref */ + fill: (name: string, ref: string, value: string) => Promise; + + /** Take screenshot of page or element */ + screenshot: (name: string, options?: { fullPage?: boolean; selector?: string }) => Promise<{ screenshot: string; mimeType: string }>; + + /** Set viewport size */ + setViewportSize: (name: string, width: number, height: number) => Promise; + + /** Wait for selector to appear */ + waitForSelector: (name: string, selector: string, options?: { timeout?: number; state?: "attached" | "detached" | "visible" | "hidden" }) => Promise; + + /** Get page URL and title */ + getInfo: (name: string) => Promise<{ url: string; title: string }>; + + /** Get server information */ + getServerInfo: () => Promise; + + /** Disconnect (no-op for HTTP client, but maintains API compatibility) */ + disconnect: () => Promise; +} + +/** + * Connect to a dev-browser server using HTTP-only protocol. + * This lightweight client doesn't require Playwright. + * + * @param serverUrl - Optional server URL. If not provided, port is auto-discovered. + */ +export async function connectLite(serverUrl?: string): Promise { + // Auto-discover port if no URL provided + if (!serverUrl) { + const port = discoverPort(); + serverUrl = `http://localhost:${port}`; + } + // Helper for JSON requests + async function jsonRequest(path: string, options?: RequestInit): Promise { + const res = await fetch(`${serverUrl}${path}`, { + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }); + + if (!res.ok) { + const error = await res.text(); + throw new Error(`HTTP ${res.status}: ${error}`); + } + + return res.json() as Promise; + } + + return { + async page(name: string) { + const result = await jsonRequest("/pages", { + method: "POST", + body: JSON.stringify({ name } satisfies GetPageRequest), + }); + return { name: result.name, targetId: result.targetId }; + }, + + async list() { + const result = await jsonRequest("/pages"); + return result.pages; + }, + + async close(name: string) { + await jsonRequest(`/pages/${encodeURIComponent(name)}`, { + method: "DELETE", + }); + }, + + async navigate(name: string, url: string, waitUntil?: "load" | "domcontentloaded" | "networkidle") { + return jsonRequest(`/pages/${encodeURIComponent(name)}/navigate`, { + method: "POST", + body: JSON.stringify({ url, waitUntil }), + }); + }, + + async evaluate(name: string, expression: string) { + const result = await jsonRequest(`/pages/${encodeURIComponent(name)}/evaluate`, { + method: "POST", + body: JSON.stringify({ expression }), + }); + if (result.error) { + throw new Error(result.error); + } + return result.result; + }, + + async getAISnapshot(name: string) { + const result = await jsonRequest(`/pages/${encodeURIComponent(name)}/snapshot`); + if (result.error) { + throw new Error(result.error); + } + return result.snapshot; + }, + + async selectRef(name: string, ref: string) { + return jsonRequest(`/pages/${encodeURIComponent(name)}/select-ref`, { + method: "POST", + body: JSON.stringify({ ref }), + }); + }, + + async click(name: string, ref: string) { + const result = await jsonRequest<{ success?: boolean; error?: string }>(`/pages/${encodeURIComponent(name)}/click`, { + method: "POST", + body: JSON.stringify({ ref }), + }); + if (result.error) { + throw new Error(result.error); + } + }, + + async fill(name: string, ref: string, value: string) { + const result = await jsonRequest<{ success?: boolean; error?: string }>(`/pages/${encodeURIComponent(name)}/fill`, { + method: "POST", + body: JSON.stringify({ ref, value }), + }); + if (result.error) { + throw new Error(result.error); + } + }, + + async screenshot(name: string, options?: { fullPage?: boolean; selector?: string }) { + const result = await jsonRequest(`/pages/${encodeURIComponent(name)}/screenshot`, { + method: "POST", + body: JSON.stringify(options ?? {}), + }); + if (result.error) { + throw new Error(result.error); + } + return { screenshot: result.screenshot, mimeType: result.mimeType }; + }, + + async setViewportSize(name: string, width: number, height: number) { + const result = await jsonRequest(`/pages/${encodeURIComponent(name)}/set-viewport`, { + method: "POST", + body: JSON.stringify({ width, height }), + }); + if (result.error) { + throw new Error(result.error); + } + }, + + async waitForSelector(name: string, selector: string, options?: { timeout?: number; state?: "attached" | "detached" | "visible" | "hidden" }) { + const result = await jsonRequest(`/pages/${encodeURIComponent(name)}/wait-for-selector`, { + method: "POST", + body: JSON.stringify({ selector, ...options }), + }); + if (result.error) { + throw new Error(result.error); + } + }, + + async getInfo(name: string) { + const result = await jsonRequest(`/pages/${encodeURIComponent(name)}/info`); + if (result.error) { + throw new Error(result.error); + } + return { url: result.url, title: result.title }; + }, + + async getServerInfo() { + const info = await jsonRequest("/"); + return { + wsEndpoint: info.wsEndpoint, + mode: (info.mode as "launch" | "extension") ?? "launch", + extensionConnected: info.extensionConnected, + }; + }, + + async disconnect() { + // No-op for HTTP client - no persistent connection to close + }, + }; +} diff --git a/skills/dev-browser/src/client.ts b/skills/dev-browser/src/client.ts index 4f2c03a..67fbe65 100644 --- a/skills/dev-browser/src/client.ts +++ b/skills/dev-browser/src/client.ts @@ -1,3 +1,21 @@ +/** + * @deprecated Use client-lite.ts instead for HTTP-only client without Playwright dependency. + * + * This client requires Playwright on the client side (~12MB+ memory overhead per agent). + * The new client-lite.ts uses pure HTTP and has only 30KB memory overhead. + * + * Migration guide: + * - Replace: import { connect } from "@/client.js" + * - With: import { connectLite } from "@/client-lite.js" + * - Replace: const page = await client.page("name") + * - With: await client.page("name") // returns { name, targetId }, not a Playwright Page + * - Replace: await page.goto(url) + * - With: await client.navigate("name", url) + * - Replace: await page.screenshot({ path }) + * - With: const { screenshot } = await client.screenshot("name") + * + * See SKILL.md for full client-lite API documentation. + */ import { chromium, type Browser, type Page, type ElementHandle } from "playwright"; import type { GetPageRequest, @@ -240,6 +258,9 @@ export async function connect(serverUrl = "http://localhost:9222"): Promise | null = null; + // Page registry for O(1) lookup by targetId - avoids expensive CDP session per page scan + const pageRegistry = new Map(); + async function ensureConnected(): Promise { // Return existing connection if still active if (browser && browser.isConnected()) { @@ -251,6 +272,9 @@ export async function connect(serverUrl = "http://localhost:9222"): Promise { try { @@ -273,14 +297,30 @@ export async function connect(serverUrl = "http://localhost:9222"): Promise { + // Fast path: O(1) registry lookup + const cached = pageRegistry.get(targetId); + if (cached && !cached.isClosed()) { + return cached; + } + + // Remove stale entry if page was closed + if (cached) { + pageRegistry.delete(targetId); + } + + // Slow path: scan all pages via CDP (only needed on first access or after page close) for (const context of b.contexts()) { for (const page of context.pages()) { let cdpSession; try { cdpSession = await context.newCDPSession(page); const { targetInfo } = await cdpSession.send("Target.getTargetInfo"); + + // Cache this page for future O(1) lookups + pageRegistry.set(targetInfo.targetId, page); + if (targetInfo.targetId === targetId) { return page; } @@ -318,15 +358,13 @@ export async function connect(serverUrl = "http://localhost:9222"): Promise auto-detection > undefined + if (!config.browser.path) { + config.browser.path = getDefaultBrowserPath(); + } else { + // Validate user-specified path exists + if (!existsSync(config.browser.path)) { + console.warn( + `Warning: Configured browser path does not exist: ${config.browser.path}\n` + + `Falling back to auto-detection...` + ); + config.browser.path = getDefaultBrowserPath(); + } + } + + return config; +} + +/** + * Get resolved browser configuration for use by server scripts. + * Returns the effective browser mode and path based on config and detection. + */ +export function getResolvedBrowserConfig(): { + mode: "external" | "standalone"; + path?: string; + userDataDir?: string; +} { + const config = loadConfig(); + const { browser } = config; + + // Determine effective mode + // IMPORTANT: We no longer fall back to standalone mode to prevent using Playwright's + // bundled Chrome. Only the user's Chrome for Testing installation should be used. + let effectiveMode: "external" | "standalone"; + + if (browser.mode === "standalone") { + // Standalone mode is explicitly requested - allow it but warn + console.warn( + `Warning: Standalone mode uses Playwright's bundled Chromium, not Chrome for Testing.\n` + + `For consistent browser behavior, use mode "auto" or "external" with Chrome for Testing.` + ); + effectiveMode = "standalone"; + } else if (browser.mode === "external") { + if (!browser.path) { + throw new Error( + `Browser mode is "external" but no browser path configured or detected. ` + + `Set browser.path in ~/.dev-browser/config.json or install Chrome for Testing.` + ); + } + effectiveMode = "external"; + } else { + // "auto" mode: use external if browser found, otherwise FAIL (don't fall back to standalone) + if (!browser.path) { + throw new Error( + `Chrome for Testing not found at standard locations.\n` + + `Set browser.path in ~/.dev-browser/config.json to your Chrome executable or app bundle.` + ); + } + effectiveMode = "external"; + } + + return { + mode: effectiveMode, + path: browser.path, + // Only include userDataDir if explicitly configured by user + // For external mode, let the browser use its default profile unless specified + userDataDir: browser.userDataDir, + }; +} + +/** + * Check if a port is available by attempting to bind to it. + * Checks both IPv4 and IPv6 to match Express's default binding behavior. + */ +export async function isPortAvailable(port: number): Promise { + // Check default binding (IPv6 on most systems, which Express uses) + const defaultAvailable = await new Promise((resolve) => { + const server = createServer(); + server.once("error", () => resolve(false)); + server.once("listening", () => { + server.close(() => resolve(true)); + }); + server.listen(port); + }); + + if (!defaultAvailable) return false; + + // Also check IPv4 for completeness + const ipv4Available = await new Promise((resolve) => { + const server = createServer(); + server.once("error", () => resolve(false)); + server.once("listening", () => { + server.close(() => resolve(true)); + }); + server.listen(port, "0.0.0.0"); + }); + + return ipv4Available; +} + +/** + * Find an available port in the configured range. + * @throws Error if no ports are available + */ +export async function findAvailablePort(config?: DevBrowserConfig): Promise { + const { portRange } = config || loadConfig(); + const { start, end, step } = portRange; + + for (let port = start; port < end; port += step) { + if (await isPortAvailable(port)) { + return port; + } + } + + throw new Error( + `No available ports in range ${start}-${end} (step ${step}). ` + + `Too many dev-browser servers may be running. ` + + `Check ~/.dev-browser/active-servers.json for active servers.` + ); +} + +/** + * Check if a process exists. + */ +function processExists(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +/** + * Load the servers file, handling both old format (pid only) and new format (ServerInfo). + */ +function loadServersFile(): Record { + if (!existsSync(SERVERS_FILE)) { + return {}; + } + + try { + const content = readFileSync(SERVERS_FILE, "utf-8"); + const data = JSON.parse(content); + + // Handle migration from old format { port: pid } to new format { port: ServerInfo } + const servers: Record = {}; + for (const [port, value] of Object.entries(data)) { + if (typeof value === "number") { + // Old format: migrate to new format + servers[port] = { + pid: value, + mode: "standalone", // Assume standalone for old entries + startedAt: new Date().toISOString(), + }; + } else { + // New format + servers[port] = value as ServerInfo; + } + } + return servers; + } catch { + return {}; + } +} + +/** + * Save the servers file. + */ +function saveServersFile(servers: Record): void { + mkdirSync(CONFIG_DIR, { recursive: true }); + writeFileSync(SERVERS_FILE, JSON.stringify(servers, null, 2)); +} + +/** + * Clean up stale entries from servers file (processes that no longer exist). + */ +function cleanupStaleEntries(servers: Record): Record { + const cleaned: Record = {}; + for (const [port, info] of Object.entries(servers)) { + if (processExists(info.pid)) { + cleaned[port] = info; + } + } + return cleaned; +} + +/** + * Register a server for coordination tracking. + * This helps coordinate shutdown behavior and orphan detection. + */ +export function registerServer( + port: number, + pid: number, + options?: { + cdpPort?: number; + browserPid?: number; + mode?: "standalone" | "external"; + } +): void { + mkdirSync(CONFIG_DIR, { recursive: true }); + + let servers = loadServersFile(); + servers = cleanupStaleEntries(servers); + + servers[port.toString()] = { + pid, + cdpPort: options?.cdpPort, + browserPid: options?.browserPid, + mode: options?.mode ?? "standalone", + startedAt: new Date().toISOString(), + }; + + saveServersFile(servers); +} + +/** + * Unregister a server and return the count of remaining servers. + */ +export function unregisterServer(port: number): number { + let servers = loadServersFile(); + delete servers[port.toString()]; + servers = cleanupStaleEntries(servers); + saveServersFile(servers); + return Object.keys(servers).length; +} + +/** + * Get the count of currently active servers. + */ +export function getActiveServerCount(): number { + const servers = loadServersFile(); + const cleaned = cleanupStaleEntries(servers); + return Object.keys(cleaned).length; +} + +/** + * Get process ID listening on a specific port (macOS/Linux). + * Returns null if no process is listening or on error. + */ +function getProcessOnPort(port: number): number | null { + try { + // Works on macOS and Linux + const output = execSync(`lsof -ti:${port}`, { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + + if (output) { + // May return multiple PIDs, take the first one + const firstLine = output.split("\n")[0] ?? ""; + const pid = parseInt(firstLine, 10); + return isNaN(pid) ? null : pid; + } + } catch { + // No process on port or lsof not available + } + return null; +} + +/** + * Information about an orphaned browser. + */ +export interface OrphanedBrowser { + cdpPort: number; + pid: number; +} + +/** + * Detect orphaned browsers - browsers running on CDP ports with no registered server. + * + * This handles crash recovery: if a server crashed without cleanup, its browser + * may still be running. This function identifies such orphans. + * + * @param cdpPorts - CDP ports to check (default: common ports 9223, 9225, etc.) + * @returns List of orphaned browsers + */ +export function detectOrphanedBrowsers(cdpPorts?: number[]): OrphanedBrowser[] { + const servers = loadServersFile(); + const cleanedServers = cleanupStaleEntries(servers); + + // Get CDP ports that have active servers + const activeCdpPorts = new Set(); + for (const info of Object.values(cleanedServers)) { + if (info.cdpPort) { + activeCdpPorts.add(info.cdpPort); + } + } + + // Default ports to check if not specified + const portsToCheck = cdpPorts ?? [9223, 9225, 9227, 9229, 9231]; + + const orphans: OrphanedBrowser[] = []; + for (const cdpPort of portsToCheck) { + // Skip if an active server claims this CDP port + if (activeCdpPorts.has(cdpPort)) { + continue; + } + + // Check if something is running on this port + const pid = getProcessOnPort(cdpPort); + if (pid !== null) { + orphans.push({ cdpPort, pid }); + } + } + + return orphans; +} + +/** + * Clean up orphaned browsers from previous crashed sessions. + * + * This is useful for standalone mode where the server owns the browser lifecycle. + * Only kills processes that are truly orphaned (no registered server). + * + * @param cdpPorts - CDP ports to check for orphans + * @returns Number of orphaned browsers cleaned up + */ +export function cleanupOrphanedBrowsers(cdpPorts?: number[]): number { + const orphans = detectOrphanedBrowsers(cdpPorts); + let cleaned = 0; + + for (const orphan of orphans) { + try { + console.log( + `Cleaning up orphaned browser on CDP port ${orphan.cdpPort} (PID: ${orphan.pid})` + ); + process.kill(orphan.pid, "SIGTERM"); + cleaned++; + } catch (err) { + console.warn( + `Warning: Could not kill orphaned process ${orphan.pid}: ${err}` + ); + } + } + + return cleaned; +} + +/** + * Output the assigned port for agent discovery. + * Agents parse this output to know which port to connect to. + * + * Format: PORT=XXXX + */ +export function outputPortForDiscovery(port: number): void { + console.log(`PORT=${port}`); +} + +/** + * Write port to tmp/port file for client discovery. + * The client-lite can read this file to find the server port. + */ +export function writePortFile(port: number, skillDir: string): void { + const portFile = join(skillDir, "tmp", "port"); + mkdirSync(join(skillDir, "tmp"), { recursive: true }); + writeFileSync(portFile, port.toString()); +} + +/** + * Read port from tmp/port file. + * Returns null if file doesn't exist or is invalid. + */ +export function readPortFile(skillDir: string): number | null { + const portFile = join(skillDir, "tmp", "port"); + try { + if (existsSync(portFile)) { + const content = readFileSync(portFile, "utf-8").trim(); + const port = parseInt(content, 10); + return isNaN(port) ? null : port; + } + } catch { + // File doesn't exist or can't be read + } + return null; +} + +/** + * Get the most recently started server from active-servers.json. + * Returns null if no servers are running. + */ +export function getMostRecentServer(): { port: number; info: ServerInfo } | null { + const servers = loadServersFile(); + const cleaned = cleanupStaleEntries(servers); + + // Save cleaned version back + if (Object.keys(servers).length !== Object.keys(cleaned).length) { + saveServersFile(cleaned); + } + + let mostRecent: { port: number; info: ServerInfo } | null = null; + let mostRecentTime = 0; + + for (const [portStr, info] of Object.entries(cleaned)) { + const startedAt = new Date(info.startedAt).getTime(); + if (startedAt > mostRecentTime) { + mostRecentTime = startedAt; + mostRecent = { port: parseInt(portStr, 10), info }; + } + } + + return mostRecent; +} + +/** + * Kill all stale servers (processes that no longer exist). + * Called on startup to clean up zombies from crashed sessions. + */ +export function killStaleServers(): number { + const servers = loadServersFile(); + let killed = 0; + + for (const [portStr, info] of Object.entries(servers)) { + if (!processExists(info.pid)) { + // Process doesn't exist, remove from registry + delete servers[portStr]; + killed++; + } + } + + if (killed > 0) { + saveServersFile(servers); + console.log(`Cleaned up ${killed} stale server entries`); + } + + return killed; +} diff --git a/skills/dev-browser/src/external-browser.ts b/skills/dev-browser/src/external-browser.ts new file mode 100644 index 0000000..dcbf723 --- /dev/null +++ b/skills/dev-browser/src/external-browser.ts @@ -0,0 +1,450 @@ +import express, { type Express, type Request, type Response, type NextFunction } from "express"; +import { chromium, type Browser, type BrowserContext, type Page } from "playwright"; +import { spawn } from "child_process"; +import type { Socket } from "net"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SKILL_DIR = join(__dirname, ".."); +import type { + GetPageRequest, + GetPageResponse, + ListPagesResponse, + ServerInfoResponse, +} from "./types"; +import { registerPageRoutes, type PageEntry } from "./http-routes.js"; +import { + loadConfig, + findAvailablePort, + registerServer, + unregisterServer, + outputPortForDiscovery, + writePortFile, + killStaleServers, +} from "./config.js"; + +/** Idle timeout in milliseconds (30 minutes) */ +const IDLE_TIMEOUT_MS = 30 * 60 * 1000; + +export interface ExternalBrowserOptions { + /** + * HTTP API port. If not specified, a port is automatically assigned + * from the configured range (default: 19222-19300, step 2). + * This enables multiple agents to run concurrently. + */ + port?: number; + /** CDP port where external browser is listening (default: 9223) */ + cdpPort?: number; + /** Path to browser executable (for auto-launch) */ + browserPath?: string; + /** User data directory for browser profile (for auto-launch) */ + userDataDir?: string; + /** Whether to auto-launch browser if not running (default: true) */ + autoLaunch?: boolean; + /** Idle timeout in ms before auto-shutdown (default: 30 minutes, 0 to disable) */ + idleTimeout?: number; +} + +export interface ExternalBrowserServer { + wsEndpoint: string; + port: number; + mode: "external-browser"; + stop: () => Promise; +} + +/** + * Check if a browser is running on the specified CDP port + */ +async function isBrowserRunning(cdpPort: number): Promise { + try { + const res = await fetch(`http://127.0.0.1:${cdpPort}/json/version`, { + signal: AbortSignal.timeout(2000), + }); + return res.ok; + } catch { + return false; + } +} + +/** + * Get the CDP WebSocket endpoint from a running browser + */ +async function getCdpEndpoint(cdpPort: number, maxRetries = 60): Promise { + for (let i = 0; i < maxRetries; i++) { + try { + const res = await fetch(`http://127.0.0.1:${cdpPort}/json/version`, { + signal: AbortSignal.timeout(2000), + }); + if (res.ok) { + const data = (await res.json()) as { webSocketDebuggerUrl: string }; + return data.webSocketDebuggerUrl; + } + } catch { + // Browser not ready yet + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error(`Browser did not start on port ${cdpPort} within ${maxRetries * 0.5}s`); +} + +/** + * Launch browser as a detached process (survives server shutdown) + * + * On macOS, if browserPath ends with .app (an app bundle), uses `open -a` + * for proper Dock icon integration. The app should handle CDP flags internally. + */ +function launchBrowserDetached( + browserPath: string, + cdpPort: number, + userDataDir?: string +): void { + // On macOS, if path is an app bundle, use `open -a` for proper Dock icon + if (process.platform === "darwin" && browserPath.endsWith(".app")) { + console.log(`Launching macOS app: ${browserPath}`); + console.log(` (App handles CDP port and user data dir internally)`); + + const child = spawn("open", ["-a", browserPath], { + detached: true, + stdio: "ignore", + }); + child.unref(); + return; + } + + // Standard launch: spawn binary directly with CDP flags + const args = [ + `--remote-debugging-port=${cdpPort}`, + "--no-first-run", + "--no-default-browser-check", + ]; + + // Only add user-data-dir if explicitly configured + // This lets the browser use its default profile when not specified + if (userDataDir) { + args.push(`--user-data-dir=${userDataDir}`); + } + + console.log(`Launching browser: ${browserPath}`); + console.log(` CDP port: ${cdpPort}`); + console.log(` User data: ${userDataDir ?? "(default profile)"}`); + + const child = spawn(browserPath, args, { + detached: true, + stdio: "ignore", + }); + child.unref(); +} + +/** + * Helper to add timeout to promises + */ +function withTimeout(promise: Promise, ms: number, message: string): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Timeout: ${message}`)), ms) + ), + ]); +} + +/** + * Serve dev-browser by connecting to an external browser via CDP. + * + * This mode is ideal for: + * - Using Chrome for Testing or other specific browser builds + * - Keeping the browser open after automation (for manual inspection) + * - Development workflows where you want to see automation in a visible browser + * + * The browser lifecycle is managed externally - this server only connects/disconnects. + */ +export async function serveWithExternalBrowser( + options: ExternalBrowserOptions = {} +): Promise { + // Clean up stale server entries on startup + killStaleServers(); + + const config = loadConfig(); + + // Use dynamic port allocation if port not specified + const port = options.port ?? await findAvailablePort(config); + const cdpPort = options.cdpPort ?? config.cdpPort; + const autoLaunch = options.autoLaunch ?? true; + const browserPath = options.browserPath; + // Only use userDataDir if explicitly provided - let browser use default profile otherwise + const userDataDir = options.userDataDir; + const idleTimeout = options.idleTimeout ?? IDLE_TIMEOUT_MS; + + // Validate port numbers + if (port < 1 || port > 65535) { + throw new Error(`Invalid port: ${port}. Must be between 1 and 65535`); + } + if (cdpPort < 1 || cdpPort > 65535) { + throw new Error(`Invalid cdpPort: ${cdpPort}. Must be between 1 and 65535`); + } + if (port === cdpPort) { + throw new Error("port and cdpPort must be different"); + } + + // Check if browser is running, optionally launch it + const running = await isBrowserRunning(cdpPort); + + if (!running) { + if (autoLaunch && browserPath) { + console.log(`Browser not running on port ${cdpPort}, launching...`); + launchBrowserDetached(browserPath, cdpPort, userDataDir); + } else if (autoLaunch && !browserPath) { + throw new Error( + `Browser not running on port ${cdpPort} and no browserPath provided for auto-launch. ` + + `Either start the browser manually with --remote-debugging-port=${cdpPort} or provide browserPath.` + ); + } else { + throw new Error( + `Browser not running on port ${cdpPort}. ` + + `Start it with --remote-debugging-port=${cdpPort}` + ); + } + } else { + console.log(`Browser already running on port ${cdpPort}`); + } + + // Wait for CDP endpoint + console.log("Waiting for CDP endpoint..."); + const wsEndpoint = await getCdpEndpoint(cdpPort); + console.log(`CDP WebSocket endpoint: ${wsEndpoint}`); + + // Connect to the browser via CDP + console.log("Connecting to browser via CDP..."); + const browser: Browser = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`); + console.log("Connected to external browser"); + + // Get the default context (user's browsing context) + const contexts = browser.contexts(); + const context: BrowserContext = contexts[0] || await browser.newContext(); + + // Registry: name -> PageEntry + const registry = new Map(); + + // Helper to get CDP targetId for a page + async function getTargetId(page: Page): Promise { + const cdpSession = await context.newCDPSession(page); + try { + const { targetInfo } = await cdpSession.send("Target.getTargetInfo"); + return targetInfo.targetId; + } finally { + await cdpSession.detach(); + } + } + + // Express server for page management + const app: Express = express(); + app.use(express.json()); + + // Idle timeout tracking + let lastActivityTime = Date.now(); + let idleTimer: ReturnType | null = null; + + // Middleware to track activity and reset idle timer + app.use((_req: Request, _res: Response, next: NextFunction) => { + lastActivityTime = Date.now(); + if (idleTimer) { + clearTimeout(idleTimer); + } + if (idleTimeout > 0) { + idleTimer = setTimeout(() => { + console.log(`\nShutting down due to ${idleTimeout / 1000 / 60} minutes of inactivity`); + cleanup().then(() => process.exit(0)); + }, idleTimeout); + } + next(); + }); + + // GET / - server info + app.get("/", (_req: Request, res: Response) => { + const response: ServerInfoResponse & { mode: string } = { + wsEndpoint, + mode: "external-browser", + }; + res.json(response); + }); + + // GET /pages - list all pages + app.get("/pages", (_req: Request, res: Response) => { + const response: ListPagesResponse = { + pages: Array.from(registry.keys()), + }; + res.json(response); + }); + + // POST /pages - get or create page + app.post("/pages", async (req: Request, res: Response) => { + const body = req.body as GetPageRequest; + const { name } = body; + + if (!name || typeof name !== "string") { + res.status(400).json({ error: "name is required and must be a string" }); + return; + } + + if (name.length === 0) { + res.status(400).json({ error: "name cannot be empty" }); + return; + } + + if (name.length > 256) { + res.status(400).json({ error: "name must be 256 characters or less" }); + return; + } + + // Check if page already exists + let entry = registry.get(name); + if (!entry) { + // Create new page in the context (with timeout to prevent hangs) + const page = await withTimeout(context.newPage(), 30000, "Page creation timed out after 30s"); + const targetId = await getTargetId(page); + entry = { page, targetId }; + registry.set(name, entry); + + // Clean up registry when page is closed (e.g., user clicks X) + page.on("close", () => { + registry.delete(name); + }); + } + + const response: GetPageResponse = { wsEndpoint, name, targetId: entry.targetId, mode: "launch" }; + res.json(response); + }); + + // DELETE /pages/:name - close a page + app.delete("/pages/:name", async (req: Request<{ name: string }>, res: Response) => { + const name = decodeURIComponent(req.params.name); + const entry = registry.get(name); + + if (entry) { + await entry.page.close(); + registry.delete(name); + res.json({ success: true }); + return; + } + + res.status(404).json({ error: "page not found" }); + }); + + // Register shared page operation routes (navigate, evaluate, snapshot, click, fill, etc.) + registerPageRoutes(app, registry); + + // Start the server + const server = app.listen(port, () => { + console.log(`HTTP API server running on port ${port}`); + }); + + // Register this server for multi-agent coordination (external mode doesn't own the browser) + registerServer(port, process.pid, { cdpPort, mode: "external" }); + + // Write port to tmp/port for client discovery + writePortFile(port, SKILL_DIR); + + // Output port for agent discovery (agents parse this to know which port to connect to) + outputPortForDiscovery(port); + + // Start the initial idle timer + if (idleTimeout > 0) { + idleTimer = setTimeout(() => { + console.log(`\nShutting down due to ${idleTimeout / 1000 / 60} minutes of inactivity`); + cleanup().then(() => process.exit(0)); + }, idleTimeout); + console.log(`Idle timeout: ${idleTimeout / 1000 / 60} minutes`); + } + + // Track active connections for clean shutdown + const connections = new Set(); + server.on("connection", (socket: Socket) => { + connections.add(socket); + socket.on("close", () => connections.delete(socket)); + }); + + // Track if cleanup has been called to avoid double cleanup + let cleaningUp = false; + + // Cleanup function - disconnects but does NOT close the browser + const cleanup = async () => { + if (cleaningUp) return; + cleaningUp = true; + + // Clear idle timer + if (idleTimer) { + clearTimeout(idleTimer); + idleTimer = null; + } + + console.log("\nShutting down..."); + + // Close all active HTTP connections + for (const socket of connections) { + socket.destroy(); + } + connections.clear(); + + // Close managed pages (pages we created, not user's existing tabs) + for (const entry of registry.values()) { + try { + await entry.page.close(); + } catch { + // Page might already be closed + } + } + registry.clear(); + + // Disconnect from browser (does NOT close it) + try { + await browser.close(); + } catch { + // Already disconnected + } + + server.close(); + + // Unregister this server + const remainingServers = unregisterServer(port); + console.log( + `Server stopped. Browser remains open. ` + + `${remainingServers} other server(s) still running.` + ); + }; + + // Signal handlers + const signals = ["SIGINT", "SIGTERM", "SIGHUP"] as const; + + const signalHandler = async () => { + await cleanup(); + process.exit(0); + }; + + const errorHandler = async (err: unknown) => { + console.error("Unhandled error:", err); + await cleanup(); + process.exit(1); + }; + + // Register handlers + signals.forEach((sig) => process.on(sig, signalHandler)); + process.on("uncaughtException", errorHandler); + process.on("unhandledRejection", errorHandler); + + // Helper to remove all handlers + const removeHandlers = () => { + signals.forEach((sig) => process.off(sig, signalHandler)); + process.off("uncaughtException", errorHandler); + process.off("unhandledRejection", errorHandler); + }; + + return { + wsEndpoint, + port, + mode: "external-browser", + async stop() { + removeHandlers(); + await cleanup(); + }, + }; +} diff --git a/skills/dev-browser/src/http-routes.ts b/skills/dev-browser/src/http-routes.ts new file mode 100644 index 0000000..bc5b1c4 --- /dev/null +++ b/skills/dev-browser/src/http-routes.ts @@ -0,0 +1,348 @@ +/** + * Shared HTTP route handlers for page operations. + * + * These routes are used by both standalone (index.ts) and external browser + * (external-browser.ts) modes. They handle all page-level operations like + * navigation, evaluation, screenshots, etc. + */ + +import type { Express, Request, Response } from "express"; +import type { Page } from "playwright"; +import { getSnapshotScript } from "./snapshot/browser-script.js"; + +/** Page entry in the registry */ +export interface PageEntry { + page: Page; + targetId: string; +} + +/** Registry type for page tracking */ +export type PageRegistry = Map; + +/** + * Register all page operation routes on an Express app. + * + * This registers routes for: + * - POST /pages/:name/navigate + * - POST /pages/:name/evaluate + * - GET /pages/:name/snapshot + * - POST /pages/:name/select-ref + * - POST /pages/:name/click + * - POST /pages/:name/fill + * - POST /pages/:name/screenshot + * - POST /pages/:name/set-viewport + * - POST /pages/:name/wait-for-selector + * - GET /pages/:name/info + */ +export function registerPageRoutes(app: Express, registry: PageRegistry): void { + // POST /pages/:name/navigate - navigate to URL + app.post("/pages/:name/navigate", async (req: Request<{ name: string }>, res: Response) => { + const name = decodeURIComponent(req.params.name); + const entry = registry.get(name); + + if (!entry) { + res.status(404).json({ error: "page not found" }); + return; + } + + const { url, waitUntil } = req.body as { url?: string; waitUntil?: "load" | "domcontentloaded" | "networkidle" }; + if (!url) { + res.status(400).json({ error: "url is required" }); + return; + } + + try { + await entry.page.goto(url, { waitUntil: waitUntil || "domcontentloaded" }); + res.json({ + url: entry.page.url(), + title: await entry.page.title(), + }); + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }); + } + }); + + // POST /pages/:name/evaluate - evaluate JavaScript + app.post("/pages/:name/evaluate", async (req: Request<{ name: string }>, res: Response) => { + const name = decodeURIComponent(req.params.name); + const entry = registry.get(name); + + if (!entry) { + res.status(404).json({ error: "page not found" }); + return; + } + + const { expression } = req.body as { expression?: string }; + if (!expression) { + res.status(400).json({ error: "expression is required" }); + return; + } + + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await entry.page.evaluate((expr: string) => eval(expr), expression); + res.json({ result }); + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }); + } + }); + + // GET /pages/:name/snapshot - get AI snapshot + app.get("/pages/:name/snapshot", async (req: Request<{ name: string }>, res: Response) => { + const name = decodeURIComponent(req.params.name); + const entry = registry.get(name); + + if (!entry) { + res.status(404).json({ error: "page not found" }); + return; + } + + try { + const snapshotScript = getSnapshotScript(); + const snapshot = await entry.page.evaluate((script: string) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const w = globalThis as any; + if (!w.__devBrowser_getAISnapshot) { + // eslint-disable-next-line no-eval + eval(script); + } + return w.__devBrowser_getAISnapshot(); + }, snapshotScript); + + res.json({ snapshot }); + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }); + } + }); + + // POST /pages/:name/select-ref - get element info by ref + app.post("/pages/:name/select-ref", async (req: Request<{ name: string }>, res: Response) => { + const name = decodeURIComponent(req.params.name); + const entry = registry.get(name); + + if (!entry) { + res.status(404).json({ error: "page not found" }); + return; + } + + const { ref } = req.body as { ref?: string }; + if (!ref) { + res.status(400).json({ error: "ref is required" }); + return; + } + + try { + const elementInfo = await entry.page.evaluate((refId: string) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const w = globalThis as any; + const refs = w.__devBrowserRefs; + if (!refs) { + throw new Error("No snapshot refs found. Call snapshot first."); + } + const element = refs[refId]; + if (!element) { + return { found: false }; + } + return { + found: true, + tagName: element.tagName, + textContent: element.textContent?.slice(0, 500), + }; + }, ref); + + res.json(elementInfo); + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }); + } + }); + + // POST /pages/:name/click - click on element by ref + app.post("/pages/:name/click", async (req: Request<{ name: string }>, res: Response) => { + const name = decodeURIComponent(req.params.name); + const entry = registry.get(name); + + if (!entry) { + res.status(404).json({ error: "page not found" }); + return; + } + + const { ref } = req.body as { ref?: string }; + if (!ref) { + res.status(400).json({ error: "ref is required" }); + return; + } + + try { + const elementHandle = await entry.page.evaluateHandle((refId: string) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const w = globalThis as any; + const refs = w.__devBrowserRefs; + if (!refs) throw new Error("No snapshot refs found. Call snapshot first."); + const element = refs[refId]; + if (!element) throw new Error(`Ref "${refId}" not found`); + return element; + }, ref); + + const element = elementHandle.asElement(); + if (!element) { + res.status(400).json({ error: "Could not get element handle" }); + return; + } + + await element.click(); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }); + } + }); + + // POST /pages/:name/fill - fill input by ref + app.post("/pages/:name/fill", async (req: Request<{ name: string }>, res: Response) => { + const name = decodeURIComponent(req.params.name); + const entry = registry.get(name); + + if (!entry) { + res.status(404).json({ error: "page not found" }); + return; + } + + const { ref, value } = req.body as { ref?: string; value?: string }; + if (!ref) { + res.status(400).json({ error: "ref is required" }); + return; + } + if (value === undefined) { + res.status(400).json({ error: "value is required" }); + return; + } + + try { + const elementHandle = await entry.page.evaluateHandle((refId: string) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const w = globalThis as any; + const refs = w.__devBrowserRefs; + if (!refs) throw new Error("No snapshot refs found. Call snapshot first."); + const element = refs[refId]; + if (!element) throw new Error(`Ref "${refId}" not found`); + return element; + }, ref); + + const element = elementHandle.asElement(); + if (!element) { + res.status(400).json({ error: "Could not get element handle" }); + return; + } + + await element.fill(value); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }); + } + }); + + // POST /pages/:name/screenshot - take screenshot + app.post("/pages/:name/screenshot", async (req: Request<{ name: string }>, res: Response) => { + const name = decodeURIComponent(req.params.name); + const entry = registry.get(name); + + if (!entry) { + res.status(404).json({ error: "page not found" }); + return; + } + + const { fullPage, selector } = req.body as { fullPage?: boolean; selector?: string }; + + try { + let screenshotBuffer: Buffer; + if (selector) { + const element = await entry.page.$(selector); + if (!element) { + res.status(400).json({ error: `Selector "${selector}" not found` }); + return; + } + screenshotBuffer = await element.screenshot(); + } else { + screenshotBuffer = await entry.page.screenshot({ fullPage: fullPage ?? false }); + } + const base64 = screenshotBuffer.toString("base64"); + res.json({ screenshot: base64, mimeType: "image/png" }); + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }); + } + }); + + // POST /pages/:name/set-viewport - set viewport size + app.post("/pages/:name/set-viewport", async (req: Request<{ name: string }>, res: Response) => { + const name = decodeURIComponent(req.params.name); + const entry = registry.get(name); + + if (!entry) { + res.status(404).json({ error: "page not found" }); + return; + } + + const { width, height } = req.body as { width?: number; height?: number }; + if (!width || !height) { + res.status(400).json({ error: "width and height are required" }); + return; + } + + try { + await entry.page.setViewportSize({ width, height }); + res.json({ success: true, width, height }); + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }); + } + }); + + // POST /pages/:name/wait-for-selector - wait for element + app.post("/pages/:name/wait-for-selector", async (req: Request<{ name: string }>, res: Response) => { + const name = decodeURIComponent(req.params.name); + const entry = registry.get(name); + + if (!entry) { + res.status(404).json({ error: "page not found" }); + return; + } + + const { selector, timeout, state } = req.body as { + selector?: string; + timeout?: number; + state?: "attached" | "detached" | "visible" | "hidden"; + }; + if (!selector) { + res.status(400).json({ error: "selector is required" }); + return; + } + + try { + await entry.page.waitForSelector(selector, { + timeout: timeout ?? 30000, + state: state ?? "visible" + }); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }); + } + }); + + // GET /pages/:name/info - get page URL and title + app.get("/pages/:name/info", async (req: Request<{ name: string }>, res: Response) => { + const name = decodeURIComponent(req.params.name); + const entry = registry.get(name); + + if (!entry) { + res.status(404).json({ error: "page not found" }); + return; + } + + try { + res.json({ + url: entry.page.url(), + title: await entry.page.title(), + }); + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }); + } + }); +} diff --git a/skills/dev-browser/src/index.ts b/skills/dev-browser/src/index.ts index 24fd619..05e4ddf 100644 --- a/skills/dev-browser/src/index.ts +++ b/skills/dev-browser/src/index.ts @@ -10,9 +10,38 @@ import type { ListPagesResponse, ServerInfoResponse, } from "./types"; +import { registerPageRoutes, type PageEntry } from "./http-routes.js"; +import { + loadConfig, + findAvailablePort, + registerServer, + unregisterServer, + outputPortForDiscovery, + cleanupOrphanedBrowsers, +} from "./config.js"; export type { ServeOptions, GetPageResponse, ListPagesResponse, ServerInfoResponse }; +// Re-export external browser mode +export { + serveWithExternalBrowser, + type ExternalBrowserOptions, + type ExternalBrowserServer, +} from "./external-browser.js"; + +// Re-export configuration utilities +export { + loadConfig, + findAvailablePort, + cleanupOrphanedBrowsers, + detectOrphanedBrowsers, + type DevBrowserConfig, + type BrowserConfig, + type BrowserMode, + type ServerInfo, + type OrphanedBrowser, +} from "./config.js"; + export interface DevBrowserServer { wsEndpoint: string; port: number; @@ -52,9 +81,12 @@ function withTimeout(promise: Promise, ms: number, message: string): Promi } export async function serve(options: ServeOptions = {}): Promise { - const port = options.port ?? 9222; + const config = loadConfig(); + + // Use dynamic port allocation if port not specified + const port = options.port ?? await findAvailablePort(config); const headless = options.headless ?? false; - const cdpPort = options.cdpPort ?? 9223; + const cdpPort = options.cdpPort ?? config.cdpPort; const profileDir = options.profileDir; // Validate port numbers @@ -77,6 +109,14 @@ export async function serve(options: ServeOptions = {}): Promise 0) { + // Give the OS a moment to release the port + await new Promise((resolve) => setTimeout(resolve, 500)); + } + console.log("Launching browser with persistent context..."); // Launch persistent context - this persists cookies, localStorage, cache, etc. @@ -92,12 +132,6 @@ export async function serve(options: ServeOptions = {}): Promise PageEntry const registry = new Map(); @@ -165,7 +199,7 @@ export async function serve(options: ServeOptions = {}): Promise { console.log(`HTTP API server running on port ${port}`); }); + // Register this server for multi-agent coordination (standalone mode owns the browser) + registerServer(port, process.pid, { cdpPort, mode: "standalone" }); + + // Output port for agent discovery (agents parse this to know which port to connect to) + outputPortForDiscovery(port); + // Track active connections for clean shutdown const connections = new Set(); server.on("connection", (socket: Socket) => { @@ -230,7 +273,10 @@ export async function serve(options: ServeOptions = {}): Promise