Skip to content

Repository files navigation

browser-farm

Stateless HTTP service that renders a URL to PDF or PNG screenshot using headless Chrome via Playwright. Runs a fixed pool of BROWSER_POOL_SIZE Chrome processes, launched eagerly at startup and routed by least-busy (never idle-evicted — only a genuinely disconnected browser process is torn down and relaunched).

Features

  • POST /pdf and POST /screenshot — render any http(s) URL, get raw bytes back synchronously.
  • Window size (DEFAULT_WINDOW_WIDTH/DEFAULT_WINDOW_HEIGHT, the Chrome process's own launch dimensions) and viewport (DEFAULT_VIEWPORT_WIDTH/ DEFAULT_VIEWPORT_HEIGHT, the per-request page content area) are separate, independently env-configurable concepts. A request's width/ height override the viewport only, within MIN/MAX_WIDTH/HEIGHT.
  • full_page (screenshot) and auto_height (screenshot or pdf) capture content beyond the requested viewport height — see API below.
  • format (pdf only) passes a named paper size (e.g. "A4") straight through to Playwright.
  • A fixed pool of BROWSER_POOL_SIZE Chrome processes is launched eagerly at startup and never idle-evicted; requests route to whichever pooled instance currently has the fewest in-flight renders.
  • Per-instance concurrency is capped (MAX_PAGES_PER_BROWSER); requests beyond the cap queue instead of spawning more browser processes.
  • Render timeout is configurable server-wide (RENDER_TIMEOUT_SECONDS) and overridable per request (timeout_ms).
  • Ships as a single self-contained container image — Chromium and all its OS dependencies are baked in, no external runtime dependency.

Non-goals (v1)

No auth, no rate limiting, no persistent storage, no multi-node coordination, no custom render options (PDF headers/footers, PDF margins). See docs/superpowers/specs/2026-08-08-browser-farm-design.md for the full design rationale.

Quick start (Docker)

docker build -t browser-farm .
docker run --rm -p 8080:8080 \
  --security-opt seccomp=./seccomp_profile.json \
  --security-opt no-new-privileges \
  browser-farm

One of the sandbox-enabling flags above is required — the container enables Chromium's real sandbox and fails fast at startup without it; see docs/docker.md for why, and for the --cap-add=SYS_ADMIN fallback if mounting a custom seccomp profile isn't possible in your environment.

curl -s -o out.pdf -w '%{http_code}\n' -X POST http://localhost:8080/pdf \
  -H 'Content-Type: application/json' \
  -d '{"url":"https://example.com"}'

curl -s -o out.png -w '%{http_code}\n' -X POST http://localhost:8080/screenshot \
  -H 'Content-Type: application/json' \
  -d '{"url":"https://example.com"}'

See docs/docker.md for full container documentation (build, run, configuration, health/troubleshooting notes).

API

Full request/response reference: docs/openapi.yaml (OpenAPI 3.0). Quick summary:

Endpoint Body Response
POST /pdf {"url", "width"?, "height"?, "timeout_ms"?, "wait_for_event"?, "auto_height"?, "format"?} application/pdf bytes
POST /screenshot {"url", "width"?, "height"?, "timeout_ms"?, "wait_for_event"?, "full_page"?, "auto_height"?} image/png bytes

url must be an absolute http/https URL. width/height set the per-request viewport (independent of the server's window size — see Configuration); omitted, they default to DEFAULT_VIEWPORT_WIDTH/ DEFAULT_VIEWPORT_HEIGHT, and if present must fall within MIN/MAX_WIDTH/HEIGHT or the request is rejected (400). timeout_ms overrides RENDER_TIMEOUT_SECONDS for that request only.

wait_for_event names a window event the target page is expected to dispatch (window.dispatchEvent(new Event("your-event-name"))) once its content is ready to capture; browser-farm waits for it (bounded by the same timeout budget as the rest of the request) after navigation and before capture. Omit it for the default behavior (capture as soon as Playwright's networkidle state is reached). If the event never fires within the timeout, the request fails the same way an ordinary navigation timeout does (504). Note: the event wait is additive — the page must still reach Playwright's networkidle state first. A page holding a persistent connection (websocket, long-poll, polling fetch) will time out at that stage regardless of whether the event fired.

full_page (screenshot only, 400 on /pdf): Playwright's native full-scrollable-page capture, ignoring the requested/default viewport height. auto_height (screenshot or pdf): measures the target page's actual content height (document.documentElement.scrollHeight) and uses that instead of the requested/default height — for /screenshot this resizes the viewport to match; for /pdf this sets an explicit page width/height sized to the content (Playwright has no native full-page concept for PDF). height is accepted but unused when either full_page or auto_height is true. full_page and auto_height together is 400. auto_height and format together on /pdf is 400 (an explicit content-sized page and a named paper format are mutually exclusive).

format (pdf only, 400 on /screenshot): a named paper format (e.g. "A4", "Letter", "Legal") passed straight through to Playwright's own PagePdfOptions.Format — Playwright's accepted values are the source of truth, not re-validated here. Omitted (and no auto_height), Playwright's own default (Letter) is used.

Status codes: 200 success, 400 invalid request, 503 pool saturated (timed out waiting for a free page slot), 504 render timed out, 500 other failure.

Configuration

All configuration is via environment variables:

Var Default Meaning
BROWSER_POOL_SIZE 1 Number of Chrome processes kept warm for the process lifetime.
DEFAULT_WINDOW_WIDTH 3840 Chrome's own launch --window-size width. Pool-wide, launch-time only — not overridable per request.
DEFAULT_WINDOW_HEIGHT 2160 Chrome's own launch --window-size height. Same constraints as above.
DEFAULT_VIEWPORT_WIDTH 1920 Per-request page viewport width used when a request omits width.
DEFAULT_VIEWPORT_HEIGHT 1080 Per-request page viewport height used when a request omits height (ignored if full_page or auto_height is true).
MIN_WIDTH / MAX_WIDTH 200 / 3840 Accepted range for a request's width.
MIN_HEIGHT / MAX_HEIGHT 200 / 3840 Accepted range for a request's height.
MAX_PAGES_PER_BROWSER 5 Concurrent pages per Chrome instance before requests queue.
RENDER_TIMEOUT_SECONDS 60 Default max time for navigation + capture. Overridable per-request via timeout_ms.
PORT 8080 HTTP listen port.
OTEL_ENABLED false Enable OpenTelemetry tracing. When true, a real OTLP/HTTP exporter is configured using the OTel SDK's own standard env vars (OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS, OTEL_SERVICE_NAME, etc. — not listed here, see the OpenTelemetry SDK environment variable spec). When false (default), tracing is fully inert — no exporter, no network calls, negligible overhead.
ALLOWED_URL_HOSTS (empty) Comma-separated wildcard host patterns (e.g. *.example.com,api.internal.corp). A leading *. matches any subdomain and the bare domain. Empty means no allowlist restriction (subject to BLOCK_PRIVATE_IPS below). An explicit match always bypasses the private-IP block. Known limitation: only the original caller-supplied URL is checked against the allowlist — an open redirect on an allowlisted host to a different, non-allowlisted (or internal) target is not revalidated (see the BLOCK_PRIVATE_IPS row).
BLOCK_PRIVATE_IPS true Reject requests whose target host resolves to a private, loopback, link-local, or unspecified (0.0.0.0/::) address (covers RFC1918, 127.0.0.0/8, 169.254.0.0/16 — including the cloud metadata endpoint — and the any-address/localhost-filter-bypass case). Known limitations: (1) this check resolves DNS at request-validation time, not at the moment the browser actually connects, so it does not fully close a DNS-rebinding attack; (2) only the original caller-supplied URL is validated — Chromium follows HTTP redirects internally with no revalidation, so a redirect (including an open redirect on an allowlisted host) to an internal/private address is not currently blocked. Closing this would require redirect-time interception (e.g. Playwright request routing) or an egress proxy — out of scope for this pass.

Breaking change: WINDOW_SIZES and IDLE_TIMEOUT_SECONDS (from earlier versions) have been removed. config.Load() silently ignores unrecognized env vars, so if either is still set in an existing deployment, startup will succeed with no warning and silently different behavior. Use BROWSER_POOL_SIZE and DEFAULT_VIEWPORT_WIDTH/DEFAULT_VIEWPORT_HEIGHT instead, and note that pooled instances are no longer idle-evicted — see the pool description above.

Tracing

Set OTEL_ENABLED=true to emit one OpenTelemetry span per /pdf or /screenshot request (attributes: render.kind, render.width, render.height — the resolved per-request viewport, not window size; errors are recorded on the span with status Error). Configure where spans go using the OTel SDK's standard environment variables — most commonly just OTEL_EXPORTER_OTLP_ENDPOINT:

docker run --rm -p 8080:8080 \
  --security-opt seccomp=./seccomp_profile.json \
  --security-opt no-new-privileges \
  -e OTEL_ENABLED=true \
  -e OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 \
  browser-farm

When OTEL_ENABLED is unset or false, no exporter is configured and no tracing-related network calls are made.

For a ready-to-run local stack (Tempo + Prometheus + Grafana, fully provisioned) to see tracing in action, see docs/docker.md.

Development

Requires Go 1.26+.

go build ./...
go vet ./...
go test ./...              # unit tests, no browser required
go test ./... -race

The Playwright-backed browser launcher has an opt-in integration test that launches real Chromium (requires it installed locally, see docs/docker.md for the install command):

go test -tags=integration ./internal/browserpool/... -run TestPlaywrightLauncher -v

Project layout

internal/config/      env-driven configuration (Size, Config, Load)
internal/browserpool/ Chrome instance pool: launch/reuse/idle-evict,
                       per-instance concurrency limiting, real Playwright
                       launcher
internal/httpapi/     HTTP handlers for /pdf and /screenshot
main.go               wiring: config -> launcher -> pool -> server
Dockerfile            self-contained container build
docs/openapi.yaml     API specification
docs/docker.md        container build/run/configuration reference
docs/superpowers/     design spec and implementation plan for this project

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages