fix(docs): load MDX bodies on demand so the Worker fits Cloudflare's 64 MiB limit - #263
Conversation
…64 MiB limit `Deploy Docs` has been rejected by the Cloudflare API on every run since #106 (2026-08-25T21:05Z) with `code: 10027` — the Worker exceeds the 64 MiB uncompressed limit. Version creation fails, so no new Worker version exists and the previously accepted one keeps being served: nothing 500s, the site just stops changing. The bytes were not the corpus. All 397 `.mdx` files are 2.50 MiB of source; `handler.mjs` measured 100.93 MiB locally on 94a4126. The multiplier was the bundling. `fumadocs-mdx:collections/server` imports every page eagerly, so each server entrypoint that touches `source` — the docs page, and also `/llms.txt`, `/llms-full.txt`, `/llms.mdx/*`, `/og/*`, `/api/search` and `/sitemap.xml` — pulled the whole corpus into its own chunk, and the bundler inlined the set five times over. Measured on one probe sentence that occurs once, in one English page: 15 copies in `handler.mjs` before, 6 after. `async: true` on the docs collection makes each page's compiled body a dynamic import, so Turbopack emits per-page chunks (27 chunk files before, 971 after) instead of one corpus-sized chunk per entrypoint. handler.mjs 100.93 MiB -> 48.47 MiB (-52.0%) The only consumer this changes is the docs page, which now awaits `page.data.load()` for `body` and `toc`. Frontmatter stays eager, so `title`, `description`, `seoTitle` and `full` are untouched, and `getText('processed')` — what the llms.txt routes call — remains a method on the entry, so the generated `llms` bodies are byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GkauAsZBEemRbco2rEX9Lx
|
Review in progress — held for one verification round, not for a defect. The diff is accepted. Checklist against GitHub, not the reportdraft ✓ · base Absent gates verified as correctly absent, not silently missing. Only two checks appear. Three load-bearing measurements re-verified independently: 397 Why it is not flipping ready yetThe browser pass ran against production The asymmetry is what makes this worth a round rather than a note. Today the site is stale-but-serving — the previous Worker version keeps answering because the upload is refused. If this merges, the upload is accepted, and the lazy chunks then fail to resolve under workerd, we trade 9 days stale for 500s in production: strictly worse than the status quo, on a repo that just demonstrated it does not notice deploy-side breakage for weeks.
Two things this PR got right that are worth naming
The declared surface breach is accepted, and it was my scoping error, not the dev's overreach. I listed four config files, none of which can reach Path-surface check before any ready flip: Generated by Claude Code |
… cache The docs Worker has two independent defects. Only one of them was known. ## 1. Every page route is unservable, and has been since 2026-08-26 Every page lives under `app/[lang]/`, so every page route is a *dynamic* route prerendered through `generateStaticParams()` — 1139 paths. OpenNext runs Next in minimal mode, where Next does not read prerendered HTML off a filesystem: it asks the configured incremental cache. `defineCloudflareConfig()` with no arguments resolves `incrementalCache` to `"dummy"`, whose `get()` throws by design, so that lookup always misses. With `dynamicParams` unset the miss falls through to an on-demand render — wasteful, but the site works. That is what the live Worker version (`69c79ee3-...`, built from `8feb90db`) does, and it is why the site is up. `export const dynamicParams = false` was then added to `app/[lang]/layout.tsx`, `app/[lang]/docs/[[...slug]]/page.tsx` and `app/og/docs/[...slug]/route.tsx` on 2026-08-26, across five separate PRs about 404 semantics. Under that flag Next refuses the on-demand render and raises `NoFallbackError`, answered by the prerendered `_not-found` route: the page 404s. Every page, every locale. Cloudflare had already started rejecting the oversized upload the evening before, so the flag never reached production and nothing showed it. ## 2. `async: true` was blamed for that and is innocent PR #263 added `async: true`, the upload was accepted for the first time in nine days, the site 404'd, and the flag was reverted. Measured on this tree under real workerd, with the repository's own `.github/scripts/smoke-docs.mjs`: base `main`, no `async: true` -> 21 findings, all four pages 404 `main` + `async: true` only -> the same 21 findings this commit -> 4/4 pages render, control still red The size fix published a defect that was already merged. It did not make one. `async: true` is restored here for its own reason: 2.50 MiB of authored MDX was being inlined once per server entrypoint that touches `source`, five times over, and `handler.mjs` measures 100.91 MiB without it against 50.83 MiB with. ## The fix `staticAssetsIncrementalCache` reads prerendered entries out of the Workers static assets this Worker already binds as `ASSETS`, under `cdn-cgi/_next_cache` — a prefix only the Worker can reach (verified: that path 404s publicly). No R2 bucket, no KV namespace, no new binding, no spend. Its documented restriction, read-only and for apps that want no revalidation, is exactly this app: `revalidate = false` on every route handler and no ISR anywhere. All 1139 prerendered routes have a cache entry (cross-checked against the prerender manifest, 0 missing), and `x-nextjs-cache` goes MISS -> HIT. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GkauAsZBEemRbco2rEX9Lx
Part of #261
Deliberately
Part of, not a closing keyword. This card's acceptance is a greenDeploy Docsrun onmain, and that run only exists after this merges. Auto-closing on merge would countMERGEDas shipped — the exact accounting error the card was filed about. Close it once run #141 or later is green.What the measurement found
All four framing hypotheses I was handed turned out to be wrong or incomplete. Measurements first, because three of them change the fix.
The card's date is off by two months and 64 runs. I enumerated all 131 surviving
deploy-docs.ymlruns (REST,per_page=100, two pages) instead of sampling the endpoints. Successes continue to run #105,8feb90db, 2026-08-25T15:36Z. The unbroken failing tail starts at run #106,c243ad79, 2026-08-25T21:05Z — not run #41 / 2026-06-22. The audit gap #42–#125 is now closed: #42–#105 are green, #106 onward are red. So the site is ~10 days stale, not ~10 weeks, and the ~37 PRs the card counts as queued behind this is an overcount.The last good deploy was already at 95.7% of the limit. Run #105 logged
Total Upload: 62747.87 KiBagainst 64 MiB. This was never healthy; it was one commit from the edge, and the commit that crossed it (c243ad79, a 15-line change tollms-full.txt/route.tsthat made output smaller) is not the cause in any meaningful sense. Anything landing that week would have tipped it.The bytes are not the content. All 397
.mdxfiles total 2.50 MiB of source.handler.mjsmeasured 100.93 MiB locally on94a4126. Content cannot be the weight; a 40x blowup is the weight.The lockfile pins
@opennextjs/cloudflare@1.19.11and did not move across the break. CI runspnpm install --frozen-lockfile, so the^1.13.0caret cannot float there. The lockfile is byte-identical at8feb90dbandc243ad79; its nearest change (d454ccb) is after the first failure. Resolved-version drift is ruled out.Where the bytes actually were
fumadocs-mdx:collections/serverimports every page eagerly. Every server entrypoint that touchessourcetherefore pulls the whole corpus into its own chunk — the docs page, and also/llms.txt,/llms-full.txt,/llms.mdx/*,/og/*,/api/searchand/sitemap.xml. The bundler then inlined that set five times over.Counted directly, using one probe sentence that occurs exactly once in one English page (
content/docs/build/automation/approvals.mdx):handler.mjsbeforehandler.mjsafter--shiki-light— one per highlighted code token — went 148,629 to 59,687 the same way.middleware.tsalready carries a comment describing this same bug class from a previous encounter: reaching the site host throughlib/seo.tspulled the fumadocs loader into the edge bundle and took it from 149,745 B to 17,375,914 B, "roughly 116x, andnext buildexits 0 either way".The change
async: trueon the docs collection. Each page's compiled body becomes a dynamic import, so Turbopack emits per-page chunks (27 chunk files before, 971 after) rather than one corpus-sized chunk per entrypoint.The single consumer this forces is the docs page, which now awaits
page.data.load()forbodyandtoc. Frontmatter stays eager, sotitle,description,seoTitleandfullare untouched, andgetText('processed')is still a method on the entry — which is why the generatedllmsbodies are unchanged.The dispatched surface was
open-next.config.ts,wrangler.jsonc,next.config.mjs,package.json,pnpm-lock.yaml. Neither file I changed is on it. Declaring rather than widening quietly:apps/docs/source.config.ts— the MDX build configuration. This is where the defect is produced; the four listed config files cannot reach it.apps/docs/app/[lang]/docs/[[...slug]]/page.tsx— 2 lines, not a choice.async: truechanges the collection's type, andtscnamed exactly these two properties.content/docs/is untouched, no dependency moved, no plan or tier was changed, and nothing moved off Workers.What I verified, and what I could not
Local gates, on
9d84730:pnpm --filter @objectos/docs run type-check— exit 0, 0 errorsopennextjs-cloudflare build— exit 0 from a cleaned.open-next/.next; reproduced twice at 48.47 MiB, and the base build reproduced at 100.90 MiB against my earlier 100.93 MiBpnpm turbo run test --force— exit 0,Cached: 0 cached(a real run, not a turbo replay)node apps/docs/scripts/gen-zh-hant.mjs --check— exit 0, 73 files byte-identicalnode .github/scripts/check-locale-surface.mjs— exit 0; sitemap 409 URLs, 0 unexpected / 0 missing;llms.txtandllms-full.txteach still carry all 63 en-only titles and none from other localescheck-node-floor --self-test(17+24+18 cases) andcheck-half-states --self-test(1551 cases) — exit 0Node runtime (production
next start) — full page rendering. Chromium via Playwright:/docs,/docs/build/automation/approvalsand/zh-Hans/docs/build/automation/approvalsall 200 with H1, 7 h2s, populated TOC and rendered code blocks; zero console errors and zero page errors./api/search?query=approvalreturns 114 hits. An OG card renders as a 60,871-byte PNG.workerd runtime (
opennextjs-cloudflare preview, real workerd 2026-05-26, no Cloudflare credentials required). This is the runtime that matters for this diff, becauseasync: trueturnsbodyandtocinto dynamic imports and dynamic-import resolution is exactly where Node and workerd can differ.next startabove does not cover it.94a4126(eager) and head9d84730(async):/api/search?query=approval22,890 B,/llms.txt14,693 B,/sitemap.xml392,938 B, one OG card 48,717 B — md5-identical in all four cases./api/searchgenuinely traverses the lazy path: 114 results of which 78 are body-derived text fragments, which underasync: truecome fromstructuredData(), a per-page dynamic import."build/automation/approvals.mdx":()=>a.A(19040), both registrations — with one that throws takes/api/searchfrom 200 / 22,890 B to 500 / 0 B, while/llms.txtand/sitemap.xml, which read the page tree and frontmatter rather than bodies, stay green at their exact byte counts. Restoring the bundle (verified byte-identical to the pre-mutation copy by md5) returns/api/searchto 200 / 22,890 B, byte-identical to the healthy run./api/searchresolves through the same generated thunk thatpage.data.load()calls. So a green/api/searchunder workerd is direct evidence that the mechanism this PR introduces resolves under workerd.NOT verified:
/docs,/docs/build/automation/approvalsand/zh-Hans/...return 404 in local preview — identically on base94a4126, same 11,455-byte Next 404 body, under both rawwrangler devandopennextjs-cloudflare preview. Prerendered page routes are simply not reachable in this project's local preview, onmainor on this branch, so the "TOC populated under workerd" assertion cannot be taken there. That is not a regression from this diff and the base control proves it, but it is also not positive evidence. What is verified under workerd is the dynamic-import mechanism; what is verified only under Node is React rendering that result into HTML.main. A smaller local bundle is evidence, not acceptance.docs.objectos.aiand theworkers.devhost return403 CONNECT tunnel failedthrough this container's proxy.The margin is real but thin, and nothing guards it
At a projected ~83% of the limit this is healthier than the last deploy that actually worked (95.7%), but the headroom is finite and every added page consumes it. Nothing in CI measures the bundle:
deploy-docs.ymlrunspnpm run deployand the size is only ever discovered by Cloudflare rejecting it — after merge, with no 500 and no visible symptom. That silent-failure mode is why this ran 35 red deploys without a card. Filed separately as #262 rather than smuggled in here.🤖 Generated with Claude Code
https://claude.ai/code/session_01GkauAsZBEemRbco2rEX9Lx