Repository navigation
Cloudflare Browser Rendering fallback for JS/SPA pages #13
Description
Context
The production extractor fetchPageContent() (apps/api/src/worker.ts ~2884) uses Firecrawl only when FIRECRAWL_API_KEY is set, otherwise falls back to raw fetchText + regex htmlToText() (line 5217). The Node core path (packages/core/src/web.ts fetchHtml) is also raw-fetch with no JS execution. As a result, SPA shells (Next.js/React <div id="root">) extract to near-empty content whenever Firecrawl is absent — a real risk during a live demo on a modern site. There is no Browser Rendering binding in apps/api/cloudflare/wrangler.jsonc (bindings today: D1, R2, AI, Queues).
Goal / user story
As a user extracting a JavaScript-rendered site, I want ContextMEM to render the page headlessly when a raw fetch returns an empty shell, so I get real content without Firecrawl being mandatory.
Acceptance criteria
- A
browserbinding is added towrangler.jsoncandWorkerEnv, and@cloudflare/puppeteeris wired in the queue consumer. -
fetchPageContentorder becomes firecrawl (if key) → Browser Rendering (if empty-shell detected) → raw fetch + htmlToText, with the engine recorded onFetchedText.engine. - An empty-shell heuristic triggers rendering: extracted main-content text below a threshold while the HTML shows SPA markers (
id="root",__next,app-root, or large inline script payload with little prose). - Rendering honors a wait (
CrawlOptions.waitForMs/ networkidle) and a hard timeout; failures degrade gracefully to raw fetch rather than failing the run. - Rendered HTML flows through the existing markdown/headings/brand path so downstream artifacts (chunks, facts, design-system) are unchanged in shape.
Implementation notes
- Touch
apps/api/cloudflare/wrangler.jsonc(add"browser": { "binding": "BROWSER" }),apps/api/src/worker.ts(WorkerEnv,fetchPageContent, empty-shell detector). Run rendering only in the queue consumer, never the request path, due to CPU/time limits. - Use
puppeteer.launch(env.BROWSER)→page.goto(url, { waitUntil: 'networkidle0' })→page.content()→ feed intohtmlToText/markdown. ReusefetchText's 1.5MB size guard semantics. - Gotchas: Browser Rendering has session/concurrency caps and per-invocation cost — gate it behind the empty-shell heuristic, cap concurrent sessions, and reuse a single browser per batch. Keep local Playwright (
packages/core/src/screenshots.ts) for the CLI; this is the hosted parity path. - Open question to resolve in PR: is Firecrawl permanent or does Browser Rendering become the default renderer (cost tradeoff)?
Sui Overflow angle
Many Sui/Walrus ecosystem sites and dapp docs are SPA-rendered; guaranteeing non-empty extraction without a paid Firecrawl key makes the demo robust on any URL a judge throws at it, including *.wal.app portals.
Dependencies
None (independent of chunking); complements the Walrus Sites on-chain enumeration issue for .wal.app targets.
Part of the ContextMEM roadmap (#4) • Sui Overflow build.
Activity
harrymove-ctrl commentedon Jun 19, 2026
Implementation Plan
Approach
Add a Cloudflare Browser Rendering binding (
BROWSER) and@cloudflare/puppeteerto the Worker, then insert a headless-render fallback into the production extractorfetchPageContent()(apps/api/src/worker.ts:2884). The render path is gated by an empty-shell heuristic: we still raw-fetch first (cheap, gives us the HTML to inspect + Walrus headers), and only spin up the browser when the fetched HTML looks like an un-hydrated SPA. BecausefetchPageContentis reached only viaextractTargetContext(:2452) →processExtractionJob(:1951) →async queueconsumer (:545), the render path is already confined to the queue consumer and never touches the request path — no extra guard needed. Key design decision: render lazily and reuse one browser per extraction with a small concurrency cap, and degrade to the existing raw-fetch +htmlToTextresult on any render failure so a run never fails because of rendering.Prerequisites & dependencies
contextmem-backendandcontextmem-backend-staging. Browser Rendering has a free tier (limited minutes/day) but real usage typically needs a Workers Paid plan; confirm before relying on it in the demo. There is no--remote-free local browser —wrangler devmust use--remoteto exercise it.@cloudflare/puppeteerinapps/api/package.json(Worker-native; do not confuse with theplaywrightstub already aliased inwrangler.jsonc).Step-by-step
apps/api/cloudflare/wrangler.jsonc. Add a top-level"browser": { "binding": "BROWSER" }and also add the same block underenv.staging— bindings are NOT inherited in this config (see the comment block in the file;ai/queues/d1/r2are redefined per-env).cd apps/api && bun add @cloudflare/puppeteer(adds toapps/api/package.jsondeps alongside@mysten/sui).WorkerEnv(apps/api/src/worker.ts:32): addBROWSER?: BrowserWorkerLike;. Define a minimal hand-rolled type next to the other*Liketypes (R2BucketLike:82,QueueLike:99) — the repo uses hand-rolled binding types, not@cloudflare/workers-types:type BrowserWorkerLike = { fetch: typeof fetch };(puppeteer'slaunch()accepts a Fetcher-shaped binding; cast at the call site).engineunions to add"browser":FetchedTextat:298(engine?: "fetch" | "firecrawl" | "browser") and the source-row type at:253(engine?: "firecrawl" | "fetch" | "browser").crawlContentHash,:2904):function isEmptySpaShell(html: string, text: string): boolean. Return true when the stripped main-content length (reusehtmlToText(html).trim().length,:5217) is below a threshold (~300 chars) AND the HTML shows SPA markers:/id=["']root["']/,__next/id=["']__next["'],id=["']app(-root)?["'],ng-version,data-reactroot, OR a high script-to-prose ratio (sum of<script>payload length ≫ stripped-text length). Keep it conservative to avoid rendering normal pages.firecrawlScrape,:2830):async function renderWithBrowser(url, env, opts): Promise<FetchedText>:import puppeteer from "@cloudflare/puppeteer";(top of file).const browser = await puppeteer.launch(env.BROWSER as any);thenconst page = await browser.newPage();await page.goto(url, { waitUntil: "networkidle0", timeout: opts.timeoutMs ?? 20000 });opts.waitForMs(map fromCrawlOptions.waitForMs,packages/core/src/types.ts:66)await new Promise(r => setTimeout(r, waitForMs)).const html = await page.content();— enforce the same 1.5 MB guard asfetchText(:2813/2816): ifhtml.length > 1_500_000truncate/throw.{ text: html, contentType: "text/html; charset=utf-8", headers: {}, engine: "browser", markdown: htmlToText(html) }.page/browseralways close; never let a render error bubble — caller falls back.fetchPageContentorder (:2884) to firecrawl (if key) → Browser Rendering (if empty-shell) → raw fetch + htmlToText:env.FIRECRAWL_API_KEY: tryfirecrawlScrape(unchanged) — Firecrawl already executes JS, so on success return it.const raw = await fetchText(url);(we need the HTML to run the heuristic and to keep Walrus headers).if (env.BROWSER && isEmptySpaShell(raw.text, htmlToText(raw.text))) { try { return await renderWithBrowser(url, env, {...}); } catch (e) { console.warn("[browser-render] fallback to raw fetch", ...); } }raw.markdown = htmlToText(raw.text); return raw;(unchanged final fallback).sourceFromSingleExtractionderives engine fromsourceText.includes("firecrawl")(:2111) andmanifest.sourceis"firecrawl-v2"|"cloudflare-fetch-extractor"(:2556). Addengine: home.engineto the manifest object inextractTargetContext(:2549), and change:2111toengine: (manifest.engine as ... ) ?? (sourceText.includes("firecrawl") ? "firecrawl" : "fetch"). This makesFetchedText.engine(incl."browser") flow intosources.jsonper AC.Promise.allSettledoverfetchPageContent(:2524) with no throttle. Add a tiny semaphore (cap ~2 concurrent renders) around the render branch, and lazily launch one browser perextractTargetContextinvocation reused across that run's empty-shell pages, closed in afinallyat the end ofextractTargetContext(:2452). Simplest threading: a smallRenderContextobject (holds the lazily-launched browser + semaphore) created inextractTargetContextand passed intofetchPageContent/renderWithBrowseras an extra optional arg (default undefined → launch-per-call). Optionally usepuppeteer.sessions(env.BROWSER)+puppeteer.connect()to reuse a warm session across batches (nice-to-have; document the cap).renderWithBrowserreturns the sameFetchedTextshape with.text= rendered HTML, so the existing markdown/headings/brand path (home.markdown ?? htmlToText(home.text)at:2561/2781, brand/design enrichment that readshome.text) is untouched. No changes to chunk/fact/design-system code.New / changed files
apps/api/cloudflare/wrangler.jsonc— addbrowserbinding (top-level +env.staging).apps/api/package.json— add@cloudflare/puppeteer.apps/api/src/worker.ts—import puppeteer;BrowserWorkerLiketype;WorkerEnv.BROWSER(:32); widenengineunions (:253,:298);isEmptySpaShell()+renderWithBrowser()helpers; rewirefetchPageContent()(:2884); thread engine into manifest (:2549) + read it insourceFromSingleExtraction(:2111); render context/semaphore inextractTargetContext(:2452).apps/api/src/worker.test.ts— new tests (below).Test plan
worker.test.ts): Mirror the existing test "uses Firecrawl when configured and falls back to raw fetch when it fails" (:454) and the fetch-stub helper at:1094. Add:vi.mock("@cloudflare/puppeteer", () => ({ default: { launch: async () => fakeBrowser } }))wherefakeBrowser.newPage()returns a page whosecontent()resolves to fully-rendered HTML.fetchso the source URL returns an SPA shell (<div id="root"></div>+ a big inline<script>, near-empty prose). Provide an env withBROWSER: {}and noFIRECRAWL_API_KEY, run an extraction job, then assertsources.jsoncontains"engine": "browser"and the rendered text landed in/site/index.md(read viaCloudflareNamespaceStore.readArtifact, same as:478/500).isEmptySpaShellfalse →engine: "fetch", browser never launched.launch()/gotothrows → result stillengine: "fetch"with rawhtmlToTextcontent, run does not fail.bunx wrangler dev --remote --config apps/api/cloudflare/wrangler.jsonc, POST an extraction for a known JS-rendered SPA (e.g. a*.wal.appportal or a Next.js docs site) with no Firecrawl key set; verify the run completes,sources.jsonshows"engine": "browser", and/site/index.mdhas real prose (not an empty shell). Capture the before/after content length as proof.bun run check(typecheck + vitest + build) — confirm the worker still bundles under the Worker size limit with@cloudflare/puppeteeradded (see Risks).Risks & gotchas
@cloudflare/puppeteeris sizeable; verify the deployed bundle stays under the gzip limit inbun-check.yml's build step. If it busts, lazy-import()puppeteer insiderenderWithBrowserso it only loads when the binding exists.wrangler dev --remote/ deployed — unit tests must mock puppeteer; don't expect it under plainvitest.networkidle0can stall on pages with long-polling/analytics — the hardtimeoutingoto+ try/finally close + graceful fallback (AC4) are mandatory.sources.jsonengine field is derived from a string match onmanifest.source(:2111), notFetchedText.engine— step 8 fixes this so"browser"is actually recorded; without it AC2 silently fails.Acceptance-criteria mapping
browserbinding in wrangler.jsonc + WorkerEnv,@cloudflare/puppeteerwired in the queue consumer → Steps 1, 2, 3, 6 (render runs only viaextractTargetContext→queue, already confined).FetchedText.engine→ Steps 4, 7, 8.waitForMs/networkidle) + hard timeout; failures degrade to raw fetch → Steps 6, 7 (try/catch fallback), 9.FetchedText), 10; verified by the unit test reading/site/index.md+ chunks.Effort: M, ~2-3 person-days (1 day binding+wiring+heuristic, 0.5 day browser reuse/semaphore, 1 day tests + remote verification + threshold tuning).
Implementation plan generated for execution (planning phase). Part of the roadmap (#4).