npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

browser-codemode

v1.0.0

Published

Headless browser server for AI agents — scrape, extract, screenshot, and execute Puppeteer code over HTTP or MCP.

Readme

browser-codemode

Headless browser server for AI agents — scrape, extract, screenshot, and execute Puppeteer code over HTTP or MCP.

Built with Bun, Puppeteer (with stealth plugin), and Hono. Ships as a Docker container with Chrome included.

Features

  • Web search — multi-provider search (Google → Brave → Bing → DuckDuckGo) with automatic fallback, no API keys needed
  • Stealth browsing — puppeteer-extra stealth plugin bypasses bot detection (local launch mode only; not applied when connecting to a remote browser via BROWSER_CDP_URL)
  • LLM extraction — send a page + prompt/schema → get structured JSON back (via OpenRouter)
  • MCP server — SSE-based Model Context Protocol for AI agent integration
  • Screenshot hosting — auto-uploads to S3-compatible storage (RustFS), returns shareable URLs
  • Markdown conversion — HTML → clean markdown with link extraction and content cleanup
  • YouTube transcripts — extract captions/subtitles from YouTube videos with timestamps, no API key needed
  • llms.txt — fetch a website's LLM-friendly content index, parse links, and optionally fetch all subpages
  • Eval — run arbitrary Puppeteer code with full browser, page, puppeteer access
  • Tab hygiene — automatic orphaned tab cleanup, configurable max pages limit

Quick Start

# Clone and configure
cp .env.example .env  # Set BROWSER_API_TOKEN at minimum

# Run with Docker
docker compose up -d

# Test
curl -s http://localhost:16050/health | jq

Environment Variables

| Variable | Required | Description | |----------|----------|-------------| | BROWSER_API_TOKEN | No | Bearer token for API + MCP auth. If unset, the service runs with no auth — only run it on a trusted/private network, never exposed to the internet. | | BROWSER_CDP_URL | No | Remote Chrome DevTools Protocol URL (uses local Chrome if unset). Stealth plugin is not applied in this mode. | | OPENROUTER_API_KEY | No | Required for /api/extract (LLM-powered extraction) | | RUSTFS_ENDPOINT_URL | No | S3-compatible endpoint for screenshot uploads | | RUSTFS_ACCESS_KEY | No | S3 access key | | RUSTFS_SECRET_KEY | No | S3 secret key | | MAX_PAGES | No | Maximum concurrent browser tabs (default: 5) | | PORT | No | HTTP port (default: 8787) | | MAX_BODY_BYTES | No | Max request body size in bytes (default: 10485760 = 10 MiB) |

Control UI

A minimal, build-free control panel is served at the root path:

GET /        → HTML control UI

Open http://localhost:16050/ in a browser. It lets you:

  • Manage persistent tabs (sessions): open a URL → the server keeps the tab alive; list and close them. Sessions auto-expire after 10 min idle (kept alive while being viewed).
  • Live-stream a tab via CDP JPEG screencast over WebSocket — stable, works everywhere, ~8–15fps (Chrome-capped).
  • Control the tab interactively — click, scroll, and type on the canvas are forwarded to the page via CDP input.
  • Code mode — run arbitrary Puppeteer against the selected live tab (page + puppeteer in scope).
  • Feature tester — simple forms to exercise every HTTP endpoint (search, markdown, scrape, fetch, extract, screenshot, transcript, llms, eval).

Auth: when BROWSER_API_TOKEN is set, the UI page itself is gated by HTTP Basic auth — the browser prompts for credentials (any username, password = the token). After authenticating, the server injects the token into the page so API and WebSocket calls authenticate automatically; no manual entry. API/MCP/WS endpoints accept the token via Authorization: Bearer, Authorization: Basic, or ?token=. The UI shares the owned-tab safety model — its sessions are pinned, tagged tabs and never touch the target Chrome's own tabs.

Session endpoints

| Method | Path | Description | |--------|------|-------------| | GET | /api/sessions | List persistent sessions (id, url, title, casting, viewers) | | POST | /api/sessions | Create a session. Body: { "url"?: string } | | POST | /api/sessions/:id/navigate | Navigate a session. Body: { "url": string } | | POST | /api/sessions/:id/eval | Run Puppeteer code against the session's page. Body: { "code": string } | | DELETE | /api/sessions/:id | Close a session and its tab | | WS | /ws/screencast?id=<id>&token=<token> | Live JPEG frames; send { type: "input", event } to control the tab |


Embedding (@yaelg/browser-codemode-embed)

Mount a live, interactive browser tab into any web app — the same streaming + control experience as the built-in UI, but in your page. The embed module is a thin, dependency-free wrapper over the session API + screencast WebSocket.

Import it straight from the server (served at /embed/browser-codemode-embed.js) or install the published package:

import { mountBrowser } from '@yaelg/browser-codemode-embed';

const handle = await mountBrowser({
  host: 'https://browser.yael.dev',
  token: 'YOUR_API_TOKEN',
  container: document.getElementById('browser'),
  url: 'https://github.com',          // create + open a session…
  // sessionId: 'abc-…',              // …or attach to an existing one
  onState: (s) => console.log(s),     // 'connecting' | 'open' | 'closed' | 'error'
  onFps:   (fps) => {},
});

await handle.navigate('https://news.ycombinator.com');
const title = await handle.eval('return document.title');
const png   = await handle.snapshot();   // Promise<Blob>
handle.setInteractive(false);            // view-only
await handle.destroy();                  // closes the session if the embed created it

The canvas forwards mouse (click + drag), wheel, and keyboard to the tab. Frames are JPEG over WebSocket; input is { type: "input", event } on the same socket.

Cross-origin: set EMBED_ALLOW_ORIGIN to the consuming app's origin (or *) so the API/embed routes send CORS headers. The token still gates every request. A self-contained demo is served at /embed/ (see embed/embed-example.html).


Authentication

When BROWSER_API_TOKEN is set, all /api/*, /mcp, the control UI (/), and the screencast WebSocket require authentication. Accepted schemes:

  • Header: Authorization: Bearer <BROWSER_API_TOKEN>
  • Header: Authorization: Basic <base64(any-username:BROWSER_API_TOKEN)> (used by the UI's browser prompt)
  • Query param: ?token=<BROWSER_API_TOKEN> (used by the WebSocket)

The control UI page is served only after Basic-auth (or ?token=) succeeds; an unauthenticated GET / returns 401 with a WWW-Authenticate: Basic challenge. GET /health requires no auth.


HTTP API

GET /health

Health check — no auth required.

{ "status": "ok", "service": "browser-codemode", "browser": { "mode": "local", "target": "local" } }

GET/POST /api/search

Search the web using the browser. Automatically falls back through providers until results are found.

| Parameter | Type | Default | Description | |-----------|------|---------|-------------| | query / q | string | required | Search query | | count | number | 10 | Number of results (1–20) | | provider | "google" \| "brave" \| "bing" \| "duckduckgo" | — | Force a specific engine (default: auto-fallback) |

# GET
curl "http://localhost:16050/api/search?q=best+rust+web+frameworks&count=5&token=$TOKEN"

# POST
curl -X POST http://localhost:16050/api/search \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"query": "best rust web frameworks", "count": 5}'

Response:

{
  "query": "best rust web frameworks",
  "provider": "google",
  "results": [
    { "title": "Top 10 Rust Web Frameworks in 2026", "url": "https://...", "snippet": "..." },
    { "title": "Actix vs Axum comparison", "url": "https://...", "snippet": "..." }
  ]
}

Fallback chain: Google → Brave → Bing → DuckDuckGo. If a provider returns a CAPTCHA, is blocked, or returns no results, the next provider is tried automatically. Cookie consent popups are dismissed automatically.


GET /api/tabs

Debug endpoint — returns current browser tab count and URLs.

curl "http://localhost:16050/api/tabs?token=$TOKEN"

Response:

{ "count": 5, "owned": 1, "active": 1, "pages": ["https://example.com"] }

count is the total tab count of the underlying browser (includes a human's tabs in remote mode); owned and pages cover only service-created tabs.


POST /api/navigate

Navigate to a URL and extract content.

| Parameter | Type | Default | Description | |-----------|------|---------|-------------| | url | string | required | URL to navigate to | | waitFor | string | "networkidle2" | Puppeteer wait condition | | extract | "text" \| "html" \| "markdown" | "text" | Content extraction format | | eval | string | — | JavaScript to run after page load, before extraction |

curl -X POST http://localhost:16050/api/navigate \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com", "extract": "markdown"}'

Response:

{ "content": "# Example Domain\n\n...", "title": "Example Domain", "url": "https://example.com" }

POST /api/scrape

Scrape a URL: returns markdown + categorized links + optional CSS selector data.

| Parameter | Type | Default | Description | |-----------|------|---------|-------------| | url | string | required | URL to scrape | | selectors | Record<string, string> | — | Map of key → CSS selector to extract | | html | boolean | false | Include raw HTML in response | | eval | string | — | JavaScript to run after page load |

curl -X POST http://localhost:16050/api/scrape \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://news.ycombinator.com", "selectors": {"titles": ".titleline > a"}}'

Response:

{
  "title": "Hacker News",
  "url": "https://news.ycombinator.com",
  "markdown": "...",
  "links": { "internal": [...], "external": [...] },
  "data": { "titles": ["Article 1", "Article 2", ...] }
}

POST /api/extract

Extract structured data from a URL using an LLM. Navigates to the page, converts to markdown, sends to OpenRouter with your prompt and/or JSON schema.

| Parameter | Type | Default | Description | |-----------|------|---------|-------------| | url | string | required | URL to extract data from | | prompt | string | — | Natural language extraction instruction | | schema | object | — | JSON Schema for structured output | | model | string | "minimax/minimax-m2.5" | OpenRouter model identifier | | eval | string | — | JavaScript to run after page load |

At least one of prompt or schema is required.

curl -X POST http://localhost:16050/api/extract \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://finviz.com/screener.ashx?v=111&s=ta_topgainers",
    "prompt": "Extract the top 10 stock gainers with ticker, company, change%, and volume",
    "schema": {
      "type": "object",
      "properties": {
        "gainers": {
          "type": "array",
          "items": {
            "type": "object",
            "properties": {
              "ticker": { "type": "string" },
              "company": { "type": "string" },
              "change_pct": { "type": "number" },
              "volume": { "type": "number" }
            }
          }
        }
      }
    }
  }'

Response:

{
  "title": "Stock Screener",
  "url": "https://finviz.com/screener.ashx?v=111&s=ta_topgainers",
  "result": { "gainers": [{ "ticker": "AAPL", "company": "Apple Inc.", "change_pct": 5.2, "volume": 1234567 }, ...] },
  "model": "minimax/minimax-m2.5",
  "usage": { "prompt_tokens": 1234, "completion_tokens": 567, "total_tokens": 1801 }
}

POST /api/screenshot

Take a screenshot of a URL. Uploads to S3-compatible storage if configured, returns shareable URL.

| Parameter | Type | Default | Description | |-----------|------|---------|-------------| | url | string | required | URL to screenshot | | fullPage | boolean | false | Capture full scrollable page | | eval | string | — | JavaScript to run before capture | | base64 | boolean | true | Include base64 image data in response |

curl -X POST http://localhost:16050/api/screenshot \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com", "fullPage": true}'

Response:

{ "url": "https://static.yael.dev/screenshots/bcm-1710000000000.png", "image": "iVBOR..." }

GET/POST /api/markdown

Fetch a URL and return clean markdown. Supports content negotiation.

| Parameter | Type | Default | Description | |-----------|------|---------|-------------| | url | string | required | URL to convert (query param for GET, body for POST) | | eval | string | — | JavaScript to run after page load | | format | "html" | — | Query param: return rendered HTML instead of raw markdown |

# Raw markdown
curl "http://localhost:16050/api/markdown?url=https://example.com&token=$TOKEN"

# Rendered HTML (browser-viewable)
curl "http://localhost:16050/api/markdown?url=https://example.com&format=html&token=$TOKEN"

Content negotiation: Accept: text/html returns rendered GFM-styled HTML page. Default returns raw markdown with Content-Type: text/markdown.


GET/POST /api/fetch

Fetch a URL through a real browser (renders JS) and return readable content. Faster than /api/navigate — uses domcontentloaded and caps output length.

| Parameter | Type | Default | Description | |-----------|------|---------|-------------| | url | string | required | URL to fetch (query param for GET, body for POST) | | extract | "markdown" \| "text" | "markdown" | Output format | | maxChars | number | 50000 | Truncate content to this many characters |

# GET
curl "http://localhost:16050/api/fetch?url=https://example.com&extract=text&token=$TOKEN"

# POST
curl -X POST http://localhost:16050/api/fetch \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com", "extract": "markdown", "maxChars": 20000}'

Response:

{ "url": "https://example.com", "extract": "markdown", "content": "# Example Domain\n\n...", "tookMs": 412 }

GET/POST /api/llms

Fetch a website's llms.txt or llms-full.txt — an LLM-friendly content index. Parses markdown links and optionally fetches linked subpages.

| Parameter | Type | Default | Description | |-----------|------|---------|-------------| | url / domain | string | required | Website URL or domain (e.g. docs.anthropic.com) | | subpages | boolean \| string[] | — | true to fetch all linked pages, or array of URL/title patterns to filter |

# GET — just the llms.txt
curl "http://localhost:16050/api/llms?url=docs.anthropic.com&token=$TOKEN"

# GET — with all subpages fetched
curl "http://localhost:16050/api/llms?url=docs.anthropic.com&subpages=true&token=$TOKEN"

# POST — filter subpages by pattern
curl -X POST http://localhost:16050/api/llms \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url": "docs.anthropic.com", "subpages": ["api", "getting-started"]}'

Response:

{
  "url": "https://docs.anthropic.com/llms.txt",
  "exists": true,
  "content": "# Anthropic Docs\n\n- [API Reference](/api): Full API docs\n...",
  "links": [
    { "title": "API Reference", "url": "https://docs.anthropic.com/api", "description": "Full API docs" }
  ],
  "pages": [
    { "url": "https://docs.anthropic.com/api", "title": "API Reference", "content": "..." }
  ]
}

Tries llms-full.txt first, falls back to llms.txt. Subpages capped at 20 pages, 50k chars each. No browser needed — pure HTTP fetch.


GET/POST /api/transcript

Extract captions/subtitles from a YouTube video. Uses the page's embedded player data to fetch timed text — no YouTube API key required.

| Parameter | Type | Default | Description | |-----------|------|---------|-------------| | url | string | required | YouTube URL or video ID (query param for GET, body for POST) | | lang | string | — | Preferred language code (e.g. "en", "fr"). Falls back to English → first available |

# GET
curl "http://localhost:16050/api/transcript?url=https://youtube.com/watch?v=dQw4w9WgXcQ&token=$TOKEN"

# POST
curl -X POST http://localhost:16050/api/transcript \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://youtube.com/watch?v=dQw4w9WgXcQ", "lang": "en"}'

Response:

{
  "videoId": "dQw4w9WgXcQ",
  "title": "Rick Astley - Never Gonna Give You Up",
  "channel": "Rick Astley",
  "duration": 212,
  "language": "en",
  "segments": [
    { "text": "We're no strangers to love", "start": 18.0, "duration": 2.5 },
    { "text": "You know the rules and so do I", "start": 20.5, "duration": 3.0 }
  ],
  "fullText": "We're no strangers to love You know the rules and so do I ..."
}

Supports full URLs (youtube.com/watch?v=, youtu.be/) and bare video IDs.


POST /api/eval

Execute arbitrary Puppeteer code. The function receives browser, page, and puppeteer in scope.

| Parameter | Type | Default | Description | |-----------|------|---------|-------------| | code | string | required | JavaScript/TypeScript code to execute |

curl -X POST http://localhost:16050/api/eval \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"code": "await page.goto(\"https://example.com\"); return await page.title();"}'

Response:

{ "result": "Example Domain" }

MCP Server

browser-codemode exposes an MCP (Model Context Protocol) server over SSE for AI agent integration.

Connection

GET  /mcp                        → SSE stream (requires Bearer auth)
POST /mcp/messages?sessionId=... → Send messages to the server

Tools

eval

Execute arbitrary Puppeteer code with browser, page, and puppeteer in scope.

| Parameter | Type | Required | Description | |-----------|------|----------|-------------| | code | string | Yes | JavaScript/TypeScript code to execute |


navigate

Open a URL and return page content.

| Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | url | string | Yes | — | URL to navigate to | | waitFor | string | No | "networkidle2" | Wait condition | | extract | "text" \| "html" \| "markdown" | No | "text" | Extraction format | | eval | string | No | — | JavaScript to run after load, before extraction |


screenshot

Take a screenshot. Returns a shareable URL (token-efficient — no base64 over MCP).

| Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | url | string | Yes | — | URL to screenshot | | fullPage | boolean | No | false | Capture full page | | eval | string | No | — | JavaScript to run before capture |

MCP screenshots return URLs only (base64 suppressed). Falls back to inline image if upload fails.


markdown

Fetch a URL and return clean markdown content.

| Parameter | Type | Required | Description | |-----------|------|----------|-------------| | url | string | Yes | URL to fetch | | eval | string | No | JavaScript to run after load |


scrape

Scrape a URL: returns markdown + categorized links + optional CSS selector data.

| Parameter | Type | Required | Description | |-----------|------|----------|-------------| | url | string | Yes | URL to scrape | | selectors | Record<string, string> | No | Map of key → CSS selector | | eval | string | No | JavaScript to run after load |


fetch

Fetch a URL through a real browser and return readable content as markdown or text.

| Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | url | string | Yes | — | URL to fetch | | extract | "markdown" \| "text" | No | "markdown" | Output format | | maxChars | number | No | 50000 | Truncate content to this many characters |


search

Search the web. Tries Google → Brave → Bing → DuckDuckGo with automatic fallback.

| Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | query | string | Yes | — | Search query | | count | number | No | 10 | Number of results (1–20) | | provider | "google" \| "brave" \| "bing" \| "duckduckgo" | No | — | Force a specific engine |


llms_txt

Fetch a website's llms.txt LLM-friendly content index.

| Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | url | string | Yes | — | Website URL or domain | | subpages | boolean \| string[] | No | — | Fetch linked pages (true=all, array=filter by pattern) |


transcript

Extract captions/subtitles from a YouTube video. No API key needed.

| Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | url | string | Yes | — | YouTube URL or video ID | | lang | string | No | — | Preferred language code (falls back to English → first available) |


extract

Extract structured data from a URL using LLM.

| Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | url | string | Yes | — | URL to extract from | | prompt | string | No* | — | Natural language extraction instruction | | schema | object | No* | — | JSON Schema for structured output | | model | string | No | "minimax/minimax-m2.5" | OpenRouter model | | eval | string | No | — | JavaScript to run after load |

*At least one of prompt or schema is required.


Architecture

┌─────────────────────────────────────────────────┐
│                  browser-codemode                │
│                                                  │
│  ┌──────────┐   ┌──────────┐   ┌──────────────┐ │
│  │ Hono API │   │ MCP SSE  │   │  Puppeteer   │ │
│  │ /api/*   │   │ /mcp     │   │  + Stealth   │ │
│  └────┬─────┘   └────┬─────┘   └──────┬───────┘ │
│       │              │                 │         │
│       └──────────────┴─────────────────┘         │
│                      │                           │
│              ┌───────┴───────┐                   │
│              │   tools.ts    │                   │
│              │ eval,navigate │                   │
│              │ scrape,extract│                   │
│              │ screenshot,md │                   │
│              │ search,       │                   │
│              │ transcript,   │                   │
│              │ llms-txt      │                   │
│              └───────┬───────┘                   │
│                      │                           │
│         ┌────────────┼────────────┐              │
│         │            │            │              │
│    ┌────┴────┐  ┌────┴────┐ ┌────┴─────┐        │
│    │ llm.ts  │  │storage  │ │markdown  │        │
│    │OpenRouter│  │  .ts    │ │  .ts     │        │
│    │extraction│  │ RustFS  │ │ turndown │        │
│    └─────────┘  └─────────┘ └──────────┘        │
│         │                                        │
│    ┌────┴────┐                                   │
│    │search.ts│                                   │
│    │ Google  │                                   │
│    │ Brave   │                                   │
│    │ Bing    │                                   │
│    │ DDG     │                                   │
│    └─────────┘                                   │
└─────────────────────────────────────────────────┘

Browser Modes

  • Local (default): Launches headless Chrome from the Docker container with stealth plugins
  • Remote: Connects to an existing Chrome via BROWSER_CDP_URL (e.g., a persistent Chrome instance)

Each request gets a fresh page (1920×1080 viewport), auto-closed after use. 3-minute timeout per request.

Tab Hygiene

The service only ever touches pages it created — it tags every page it opens and never closes, counts, or inspects tabs belonging to the underlying browser. This is essential in remote/CDP mode, where the Chrome instance is shared with a human user.

  • Owned-only: cleanup, eviction, and /api/tabs URL listing apply solely to service-created pages. The human's tabs on a shared remote Chrome are never closed or leaked.
  • Max pages limit: configurable via MAX_PAGES (default: 5), counting only service-owned pages. When reached, the service's own orphaned tabs are evicted.
  • Background cleanup: every 60 seconds, service-owned pages not tied to an active request are closed.
  • Remote safety: in remote mode the service does not resize the shared browser window; it sets only a per-tab render viewport (via emulation), which doesn't move the window the human sees.
  • Chrome flags (local mode): --disable-background-timer-throttling and --disable-backgrounding-occluded-windows prevent background tab throttling.

Markdown Pipeline

HTML → markdown conversion includes:

  • Pre-cleaning: strips nav, footer, forms, buttons, cookie/consent modals, ads
  • Removes tracking pixels, empty images, data URI images
  • Turndown with custom rules: skips decorative images, strips non-http links
  • Post-processing: strips leaked HTML, decodes entities, removes UI noise
  • Link extraction: deduped, categorized (internal/external), generic text filtered

Docker

services:
  browser-codemode:
    build: .
    ports:
      - "16050:8787"
    environment:
      - BROWSER_API_TOKEN=your-secret-token
      - OPENROUTER_API_KEY=sk-or-...     # For /api/extract
      - RUSTFS_ENDPOINT_URL=...           # For screenshot uploads
      - RUSTFS_ACCESS_KEY=...
      - RUSTFS_SECRET_KEY=...
    restart: unless-stopped
    shm_size: "1gb"  # Required for Chrome

Base image: ghcr.io/puppeteer/puppeteer:25.1.0 (Debian + Chrome + Node), pinned to match puppeteer-core. Chrome is discovered at runtime, so bumping the base image doesn't require code changes.

Deployment

Two browser backends:

  • Self-contained (local mode) — leave BROWSER_CDP_URL unset. The container launches its own headless Chrome. Simplest; good for pure scraping.
  • Remote Chrome (remote mode) — set BROWSER_CDP_URL to a Chrome DevTools endpoint. Use this for a persistent, logged-in profile. See host/ for the macOS setup (Launch Agent + socat over Tailscale).

Reference deployment (Coolify + Tailscale)

This is the setup this repo runs in production:

  1. Mac host runs Chrome on 127.0.0.1:9222, exposed on the Tailscale IP via socat, kept alive by a Launch Agent. Install per host/README.md.

  2. browser-codemode runs as a Coolify application on a separate server that is on the same tailnet. It connects out to the Mac's Chrome.

  3. Set these env vars on the Coolify app (runtime):

    | Var | Example | |-----|---------| | BROWSER_API_TOKEN | a long random secret | | BROWSER_CDP_URL | http://<mac-tailscale-ip>:9222 | | OPENROUTER_API_KEY | sk-or-... (for /api/extract) | | RUSTFS_ENDPOINT_URL / RUSTFS_ACCESS_KEY / RUSTFS_SECRET_KEY | screenshot hosting |

    In Coolify, mark BROWSER_CDP_URL as runtime (not just buildtime) or the container won't see it and will silently fall back to local mode.

  4. Open the control UI at http://<app-host>:16050/ and authenticate with the token (Basic-auth prompt).

Operational gotcha: the Mac's Tailscale IP can change. If BROWSER_CDP_URL points at a stale IP, the service stays healthy (/health doesn't touch Chrome) but every page open hangs/times out. Verify with curl http://<ip>:9222/json/version from the app server, and check GET /health reports the expected browser.target.

License

MIT