All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
0.6.1 - 2026-09-16
- Speculative tier-0 racing — Obscura gets
VEILRENDER_OBSCURA_TIMEOUTms (default 10s) to succeed; if slow, tier-1 starts immediately and the first to finish wins (#45) - Hard request deadline —
VEILRENDER_REQUEST_DEADLINE(default 45s) wraps the entire render/screenshot pipeline; returns 504 if exceeded (#46, #51) - Per-IP rate limiting —
VEILRENDER_RATE_LIMIT(e.g."10/60") uses vendored zerodep sliding-window limiter; returns 429 withRetry-After(#47, #53) - Request queue depth limit —
VEILRENDER_MAX_QUEUEcaps waiting requests; returns 503 when overloaded (#48, #52) - CDP proxy tier routing —
/cdpdefaults to tier-1 (CloakBrowser) for full Chromium compatibility;?tier=0opts into Obscura (#50, #51) ObscuraWorker.force_wait_until = "load"— forces Obscura to useloadinstead ofnetworkidleto prevent CDP hangs (#45)- Stuck worker detection — health loop detects workers with stale active slot count for >2× timeout and force-restarts them (#45)
WaitUntiltype alias for Playwright'swait_untilparameter (#45)- Test release workflow (
test-release.yml) for TestPyPI + dev Docker images - Vendored zerodep
ratelimitmodule (sliding window, token bucket, GCRA) (#53)
- Semaphore slot leaks —
asyncio.shieldprevented cancellation of tier-0 tasks; replaced with direct cancel + fire-and-forget for unresponsive Obscura CDP - Stuck detection only triggered on full occupancy (all slots); now triggers on any stale active count
- Full-page screenshot timeout — reserved navigation timeout budget for the capture step; explicit screenshot timeout derived from remaining deadline (#54)
- Font CSS pipeline — replaced 7 external CDN fetches per screenshot with locally cached inline CSS; font URLs rewritten to local
/fonts/fontsource/endpoint;@font-facerules deduplicated across scripts; multi-CDN racing (jsDelivr, unpkg, esm.sh) for initial download
BrowserManager.get_cdp_url()gainsmin_tierparameter (default 1) (#50)BrowserManager.get_page()yields 4-tuple(ctx, page, engine_label, force_wait_until)(#45)- Render/screenshot pipeline extracted into
_render_pipeline()/_screenshot_pipeline()for deadline wrapping (#46) - Font injection uses
add_style_tag(content=...)instead ofadd_style_tag(url=...)— no external network during screenshots
0.6.0 - 2026-09-15
- Obscura headless browser as tier-0 worker — lightweight Rust-based browser (~30MB memory, ~85ms page loads) with built-in stealth, auto-downloaded from GitHub releases (#41, #42)
- Two-tier fallback system — try Obscura first (tier 0), automatically fall back to CloakBrowser/Camoufox (tier 1) on failure (#41, #42)
ObscuraWorkerclass with local subprocess management, CDP connection, and auto-restart (#42)- Tier system:
_BaseWorker.tierattribute, tier-aware_pick_worker()dispatch preferring lowest available tier (#42) obscura://protocol prefix for remote Obscura workers in pool mode (#42)VEILRENDER_OBSCURA=trueenv var to enable local two-tier mode (#42)VEILRENDER_OBSCURA_MAX_CONCURRENTfor independent Obscura concurrency limits (#44)- Configurable
X-Render-Engineresponse header with opaque engine labels (alpha/beta), enabled viaVEILRENDER_ENGINE_HEADER=true(#43) - Per-tier ring gauges on dashboard — shows independent capacity for each tier when multiple tiers are configured (#44)
BrowserManager.tier_stats()for aggregated per-tier metrics in/statsJSON API (#44)scripts/download-obscura.pyfor build-time binary download (#42)OBSCURA_BINARY,OBSCURA_MIRROR,OBSCURA_VERSIONenv vars for binary management (#42)
BrowserManager.get_page()yields 3-tuple(ctx, page, engine_label)instead of 2-tuple (#43)- Dashboard metrics layout changed from CSS grid to flexbox to support variable number of ring gauges (#44)
- Dockerfile
fulltarget downloads both CloakBrowser and Obscura binaries (#42) tarfile.extractall()calls usefilter='data'to mitigate CVE-2007-4559 path traversal (#42)
0.5.1 - 2026-09-09
- Bump vendored httpserver 0.2.1→0.4.0: adds lifespan hooks, cookie support,
Statenamespace,StreamingResponsebackground callbacks, thread-safe shutdown (#38) - Refactor browser lifecycle to use
@app.on_startup/@app.on_shutdownlifespan hooks instead of manual management in server loop (#39, #40)
0.5.0 - 2026-08-26
/screenshotendpoint:format,quality,scale,selector,clip,color_scheme,wait_for,transparentparameters (#30)
0.4.1 - 2026-08-25
- Readability extraction always returned
null— vendoredreadabilitymodule failed to import siblingsoupmodule in flat vendor layout (#29)
- Bump vendored readability 0.2.0→0.2.1 (upstream fix for flat sibling imports)
0.4.0 - 2026-08-24
- Remote browser worker pool for horizontal scaling —
VEILRENDER_WORKERSenv var routes requests to remote CDP endpoints via least-connections routing (#25) - Firefox/Camoufox worker support —
playwright://protocol prefix for connecting to Playwright-compatible browsers (Camoufox, etc.) alongside CDP workers (#27) LocalWorker/RemoteWorker/PlaywrightWorkerabstraction with shared_BaseWorkerinterface (#25, #27)- Worker health checks with automatic reconnection for remote workers (#25)
- CDP proxy
?worker=Nquery param for targeted worker routing (#25) - Per-worker Prometheus gauges:
veilrender_worker_healthy,veilrender_worker_active_pages,veilrender_worker_browser_pages(#25) - Real browser page count via CDP
/jsonendpoint for accurate capacity reporting (#25) - Prometheus metrics endpoint at
GET /metrics— zero-dependency exposition format with uptime, browser status, active pages, request counters, cache lookups, and latency summaries (p50/p95) (#23) - Dashboard i18n: language selector dropdown (en/zh) with localStorage persistence; extensible via dict — adding a language only requires a new entry (#22)
- Dashboard
GET /statsJSON API for live data polling (#22) - SVG ring gauge for capacity visualization on dashboard (#22)
- Author credit and badges in dashboard footer (#22)
- Multi-target Dockerfile:
gateway(336MB, no browser) andfull(1.07GB, CloakBrowser embedded) deploy/compose.yaml(single-instance),deploy/compose-pool.yaml(Chromium pool),deploy/compose-pool-mixed.yaml(Chromium + Camoufox mixed pool)deploy/Dockerfile.camoufoxfor Camoufox server worker image (#27)POOL=1flag formake deploy-devto deploy in pool modeCLOAKBROWSER_MIRRORenv var for China downloads (e.g.https://ghfast.top)CLOAKBROWSER_BINARYenv var to use a custom browser binary path- Auto-download CloakBrowser binary from GitHub Releases without pip package
scripts/download-cloakbrowser.pyfor standalone binary download
- Migrate from Playwright to Patchright — drop-in replacement with stealth driver patches for Chromium (#25)
- Replace
miniowith vendored zerodepS3Client— onlypatchrightremains as runtime dependency (#27) - Remove
cloakbrowserpip dependency — binary downloaded directly from GitHub Releases, stealth args generated inline - Prune 8 unused vendor modules (368K → 184K): config, dotenv, yaml, jsonx, markdown, structlog, useragent, retry (#27)
- Docker image size: full 1.43GB → 1.07GB, gateway 498MB → 336MB (removed playwright transitive dep + multi-stage libgbm extraction)
- Dashboard redesign: dark theme inspired by modal.com — pure black background, mint-green accents, DM Sans + JetBrains Mono typography (#22)
- Dashboard data refresh: replaced
<meta http-equiv="refresh">full-page reload with fetch-based polling — no more flashing or scroll reset (#22) - Dashboard shows stale-data indicator (dims) after 3 consecutive fetch failures (#22)
- Dashboard pauses polling on hidden tab via Page Visibility API (#22)
- Shields.io badges use unified dark-green theme colors (#22)
- Vendored modules: replace deprecated
jsoncwithjsonx, removebenchmark_compare(#22) - Bump vendored config 0.3.0→0.3.1, httpserver 0.1.0→0.2.1, readability 0.1.0→0.2.0
- New config:
VEILRENDER_WORKERS,VEILRENDER_WORKER_MAX_CONCURRENT,VEILRENDER_WORKER_HEALTH_INTERVAL(#25)
- HF Spaces badge (space was taken down) (#22)
cloakbrowserpip dependency (binary auto-downloaded instead)miniopip dependency (replaced by vendored S3Client) (#27)
0.3.1 - 2026-07-04
- URL validation before rendering: only
http://andhttps://schemes allowed;file://,data:,javascript:blocked - DNS resolution with private IP blocking: loopback, RFC1918, link-local, cloud metadata (169.254.x.x) all rejected — prevents SSRF
- Fail2ban-style IP rate limiting: 3 auth failures in 5 minutes → 10 minute ban, with
X-Forwarded-Forsupport
- Reduce
max_body_sizefrom 10 MB to 64 KB — render/screenshot payloads are small JSON - Promote blocked-request filter log from DEBUG to INFO for production visibility
- CRITICAL: SSRF via
file:///etc/passwd— Playwrightpage.goto()accepted arbitrary schemes - CRITICAL: SSRF via
http://127.0.0.1:9222— CDP debug endpoint exposed WebSocket URLs enabling RCE - CRITICAL: No URL validation — user-supplied URLs forwarded to Playwright without sanitization
- MEDIUM:
data:URLs accepted — attacker could render arbitrary HTML content
0.3.0 - 2026-06-26
- Outbound request filtering using StevenBlack/hosts community blocklist (~82k ad/tracker/malware domains), blocking at the Playwright level via
page.route()(#21) - Two-tier render cache: L1 in-memory
TTLCache+ L2 S3-compatible persistent storage viaminioclient — supports Cloudflare R2, Oracle Object Storage, AWS S3, etc. (#21) - L2 TTL enforcement via embedded
_stored_attimestamp, checked on read - S3 lifecycle rule auto-set on startup (7× TTL expiry as bulk cleanup safety net)
make update-blocklisttarget to refresh blocklist from jsDelivr CDN- New env vars:
VEILRENDER_RESOURCE_FILTER,VEILRENDER_CACHE_ENABLED,VEILRENDER_CACHE_TTL,VEILRENDER_S3_ENDPOINT, etc.
0.2.0 - 2026-06-13
- Stats dashboard at
GET /with badges, capacity bar, and responsive layout (#20) - Public HF Space (no auth) with live demo badge
.dockerignoreand Docker safety CI
- Bump default
max_concurrentfrom 3 to 5 - Pre-download Chromium as runtime user to avoid re-download on startup
- HF Space deploy: delete symlink before writing README
- Pre-commit formatting for dashboard.py
0.1.0 - 2026-06-12
- Core rendering API with three endpoints:
POST /render— URL → rendered HTML / Markdown / readability-extracted textPOST /screenshot— URL → PNG screenshotGET /health— liveness check
- CDP WebSocket proxy at
/cdpfor direct browser control (#3) - Token-based authentication via
Authorization: Bearerheader or?token=query param - Per-request browser context isolation with configurable concurrency (
VEILRENDER_MAX_CONCURRENT) - CloakBrowser integration — stealth Chromium with 58 source-level anti-fingerprint patches (#14, #15)
- 13 vendored zerodep modules — only external dependency is
cloakbrowser - Docker image based on
python:3.12-slimwith multi-arch support (amd64/arm64) - GitHub CI pipeline: pre-commit (ruff + ty), Docker build to GHCR + Docker Hub, auto-deploy to HF Spaces
- PyPI publishing on tag push
Makefilewithdeploy-dev(VPS) anddeploy-hf(HF Spaces) targets- Bilingual README (en/zh)
- CDP proxy: preserve FIN bit during WebSocket frame forwarding (#4, #12)
- HTTP multiplexer: pipe remaining POST body data instead of
feed_eof()(#5, #9) - CDP proxy: add 16 MB max frame size limit to prevent OOM (#6, #10)
- CDP proxy: cancel both directions when one side closes (#7, #11)
- CDP proxy: use random WebSocket key per connection and forward
Sec-WebSocket-Protocol(#8, #13) - Docker: use
CLOAKBROWSER_CACHE_DIRso build-time binary download is available at runtime (#16, #17) - Launch Chromium directly via
subprocess.Popen+connect_over_cdp()instead of Playwright'slaunch(), which overrides--remote-debugging-port