feat: add Cloudflare browser backend and bound Puppeteer resource usage - #72
Conversation
There was a problem hiding this comment.
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.
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.
dfda2df to
ab9adce
Compare
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.
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.
Summary
This PR makes Puppeteer execution configurable and resource-bounded:
localandcloudflarebrowser backends behindBROWSER_BACKEND.scrapterminology toscrape.The URL resolver gRPC response shape remains unchanged.
Browser backends
Local
BROWSER_BACKEND=localremains 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=cloudflareconnects to Cloudflare Browser Rendering over CDP usingpuppeteer-core.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:
scrape()call opens the session.scrape()reconnects on demand.This avoids repeatedly acquiring billed browser sessions while the resolver is idle.
Resource bounds
Global scrape concurrency
A module-level
p-limitsemaphore limits concurrent Puppeteerscrape()calls across all in-flight gRPC requests.Only the Puppeteer fallback is limited.
normalize,unshorten, andparseMetaretain their existing concurrency.Lower values reduce Chromium memory pressure; higher values improve tail latency when sufficient memory is available.
Resource interception
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:
request.abort()andrequest.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 be0x0, so this fallback may select the first image rather than the largest one.Operators that require the previous rendered-image behavior can set:
Compatibility
BROWSER_BACKEND=localremains the default.UNKNOWN_SCRAP_ERRORwas renamed toUNKNOWN_SCRAPE_ERROR, retaining protobuf numeric value6.3and blockingimage,media, andfontresources.Verification
npm run compilenpm test: 7 suites, 35 tests, 83 snapshotsnpm run lint