Skip to content

feat: add Cloudflare browser backend and bound Puppeteer resource usage - #72

Merged
MrOrz merged 11 commits into
masterfrom
feat/cloudflare-browser-rendering
Jul 22, 2026
Merged

feat: add Cloudflare browser backend and bound Puppeteer resource usage#72
MrOrz merged 11 commits into
masterfrom
feat/cloudflare-browser-rendering

Conversation

@nonumpa

@nonumpa nonumpa commented Jun 5, 2026

Copy link
Copy Markdown
Member

Summary

This PR makes Puppeteer execution configurable and resource-bounded:

  • Adds local and cloudflare browser backends behind BROWSER_BACKEND.
  • Limits concurrent Puppeteer scrapes across all gRPC requests.
  • Blocks image, media, and font requests by default to reduce browser memory and network usage.
  • Renames the internal scrap terminology to scrape.

The URL resolver gRPC response shape remains unchanged.

Browser backends

Local

BROWSER_BACKEND=local remains the default.

The resolver launches one local Chromium browser per process and opens one page per concurrent scrape() call. Local Chromium is started eagerly and relaunched immediately after an unexpected disconnect.

Cloudflare Browser Rendering

BROWSER_BACKEND=cloudflare connects to Cloudflare Browser Rendering over CDP using puppeteer-core.

BROWSER_BACKEND=cloudflare
CLOUDFLARE_ACCOUNT_ID=...
CLOUDFLARE_API_TOKEN=...
CLOUDFLARE_KEEP_ALIVE_MS=60000

Backend selection is explicit. A Cloudflare connection failure does not automatically fall back to local Chromium; operators can switch back by setting BROWSER_BACKEND=local.

Cloudflare sessions are acquired lazily:

  • No session is created when the resolver process starts.
  • The first scrape() call opens the session.
  • When Cloudflare closes an idle session, the browser handle is cleared.
  • The next scrape() reconnects on demand.
  • A failed connection does not leave the resolver pinned to a rejected promise.

This avoids repeatedly acquiring billed browser sessions while the resolver is idle.

Resource bounds

Global scrape concurrency

SCRAPE_MAX_CONCURRENCY=3

A module-level p-limit semaphore limits concurrent Puppeteer scrape() calls across all in-flight gRPC requests.

Only the Puppeteer fallback is limited. normalize, unshorten, and parseMeta retain their existing concurrency.

Lower values reduce Chromium memory pressure; higher values improve tail latency when sufficient memory is available.

Resource interception

SCRAPE_BLOCK_RESOURCES=image,media,font

Puppeteer aborts these resource types before they are downloaded. Documents, stylesheets, scripts, XHR, and fetch requests remain enabled so client-rendered pages can still build their DOM.

Set the variable to an empty string to disable interception:

SCRAPE_BLOCK_RESOURCES=

request.abort() and request.continue() rejections caused by a page closing mid-request are handled to avoid unhandled rejections on Node.js 24.

Image-selection trade-off

Open Graph image URLs are read from HTML metadata and do not require the image itself to be downloaded.

When no Open Graph image is available, scrape() falls back to selecting the largest rendered <img>. With image requests blocked, image dimensions may be 0x0, so this fallback may select the first image rather than the largest one.

Operators that require the previous rendered-image behavior can set:

SCRAPE_BLOCK_RESOURCES=

Compatibility

  • BROWSER_BACKEND=local remains the default.
  • The Docker image still includes Chromium, allowing operators to switch from Cloudflare back to local without rebuilding the image.
  • The gRPC wire format and response fields are unchanged.
  • UNKNOWN_SCRAP_ERROR was renamed to UNKNOWN_SCRAPE_ERROR, retaining protobuf numeric value 6.
  • Default runtime behavior changes by limiting concurrent scrapes to 3 and blocking image, media, and font resources.

Verification

  • npm run compile
  • npm test: 7 suites, 35 tests, 83 snapshots
  • npm run lint
  • Docker build
  • Local browser smoke test
  • Cloudflare connection and lifecycle unit tests
  • Concurrency-limit unit test
  • Resource-interception unit tests

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request upgrades the project to Node 24, migrates from the deprecated grpc package to @grpc/grpc-js, and introduces support for Cloudflare Browser Rendering as an alternative browser backend. It also adds resource limits for scraping, such as blocking specific resource types and capping concurrency using p-limit. However, several critical issues must be addressed: the gRPC server will not accept requests because server.start() was omitted from the new asynchronous binding callback; unhandled promise rejections from browser launching and request interception could crash the Node 24 process; using new Function for evaluating Readability.js will fail on sites with strict Content Security Policies; and wrapping the entire resolution pipeline in limit unnecessarily throttles fast, non-Puppeteer operations.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/lib/scrape.js
Comment thread index.js
Comment thread src/lib/scrape.js
Comment thread src/lib/scrap.js
Comment thread src/resolvers/resolveUrls.js Outdated
nonumpa added 8 commits June 9, 2026 10:36
A new launchOrConnect() helper picks between puppeteer.launch() (local
chromium) and puppeteer-core.connect() (Cloudflare Browser Rendering
WebSocket CDP) based on BROWSER_BACKEND env. Default stays local so
existing deployments are unchanged.

Cloudflare credentials live in CLOUDFLARE_ACCOUNT_ID +
CLOUDFLARE_API_TOKEN and the required token scope is Browser Rendering:
Edit. keep_alive defaults to the documented 600000 ms (10 min) ceiling
and can be overridden via CLOUDFLARE_KEEP_ALIVE_MS.

puppeteer-core is added as a dependency at the same major version as
puppeteer to keep CDP wire protocols aligned.
scrap.js now obtains its browser via launchOrConnect(), so the same
binary can run with local chromium (default) or Cloudflare Browser
Rendering (BROWSER_BACKEND=cloudflare). All downstream code paths -
targetcreated handler, disconnected reconnect, page-level operations -
work identically against either backend because puppeteer.connect()
returns the same Browser interface as puppeteer.launch().
Note Workers Paid plan requirement (Free tier 10 min/day is insufficient
for production URL resolution) and indicative cost at 10k/100k URLs per
day so operators can size their Cloudflare account before flipping the
flag.
A five-URL request fans out into five concurrent puppeteer pages, each holding ~50 MB. Under bursty load this adds enough memory pressure to OOM the container; puppeteer auto-reconnects on disconnect without backoff, so the cycle repeats.

Wrap each scrap call in a module-level p-limit semaphore so the cap holds across all in-flight gRPC streams, not per call. Default 3, override with SCRAP_MAX_CONCURRENCY.
Loading these resource types only inflates JS heap and network sockets; none of title, summary, canonical, or og:image-driven topImageUrl reads them. With image loading skipped, the topImageUrl fallback that walks <img> by rendered size returns the first image instead of the largest, so sites without og:image lose the "largest image" semantics. Sites with og:image (the common case) are unaffected.

Default block list image,media,font; clear via SCRAP_BLOCK_RESOURCES= to disable.
Document the concurrency cap and resource block list with the topImageUrl fallback caveat so operators can tune or disable them per workload.
page.on('request', ...) handlers ignore the promises returned by req.abort()/req.continue(). If the page closes or the request was already resolved mid-flight (navigation cancellation, target closed), the rejection surfaces as an unhandled promise rejection on Node 24.
The semaphore exists to bound puppeteer page memory, but wrapping the entire per-url pipeline also serialised normalize/unshorten/parseMeta. A few slow metadata-only urls could then block unrelated fast results without ever opening Chromium.
@nonumpa
nonumpa force-pushed the feat/cloudflare-browser-rendering branch from dfda2df to ab9adce Compare June 9, 2026 10:38
@nonumpa
nonumpa changed the base branch from master to feat/upgrade-deps June 9, 2026 10:39
The eager launchBrowser() at module load opens a remote browser session on process start even before any request arrives. The disconnected handler then immediately reconnects, so when Cloudflare auto-closes the session after the keep_alive inactivity window (max 10 min), we acquire a fresh session and the cycle repeats forever, burning quota and browser-time at idle.

Split the lifecycle by backend: local stays eager (no billing, hot crash recovery wanted), cloudflare connects lazily on first scrap() and clears browserPromise on disconnect so the next scrap reconnects on demand. Also surface launchOrConnect rejections via a catch handler that resets browserPromise, so a transient acquire failure does not pin the resolver to a rejected promise.
@nonumpa nonumpa changed the title feat: Cloudflare Browser Rendering backend + bound scrap memory feat: Cloudflare Browser Rendering backend + bound puppeteer memory Jun 9, 2026
nonumpa added 2 commits June 9, 2026 12:52
The previous 600000 ms (10 min) default was chosen when scrap eagerly relaunched on disconnect, so the keep_alive window only controlled churn frequency, not the bill — one session was always open either way.

With lazy lifecycle (5c2dff8), the session only exists during/around traffic, and the keep_alive window is now a trade-off between scrap-to-scrap reuse and billed idle tail after the last request (CF bills until session timeout). Use CF's own 60s default; operators can widen for sparse traffic via CLOUDFLARE_KEEP_ALIVE_MS.
The repo has used scrap as the verb/noun for 'fetch + render + extract' since the first commit, but the correct English spelling is scrape. Rename module files (scrap.js, ScrapResult.js, mocks, tests), function and class identifiers, env var prefixes (SCRAPE_MAX_CONCURRENCY, SCRAPE_BLOCK_RESOURCES), the proto enum (UNKNOWN_SCRAPE_ERROR), README prose, and snapshot fixtures.

Wire-format ResolveError numeric values are unchanged, so existing gRPC clients keep working without redeploy. Downstream code that imports the symbolic name UNKNOWN_SCRAP_ERROR will need to update on next proto re-sync.
Base automatically changed from feat/upgrade-deps to master June 28, 2026 04:44
@nonumpa nonumpa changed the title feat: Cloudflare Browser Rendering backend + bound puppeteer memory feat: add Cloudflare browser backend and bound Puppeteer resource usage Jul 16, 2026
@nonumpa
nonumpa marked this pull request as ready for review July 16, 2026 11:26
@nonumpa
nonumpa requested a review from MrOrz July 16, 2026 11:27

@MrOrz MrOrz left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@MrOrz
MrOrz merged commit 859902d into master Jul 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants