Skip to content

fix(docs): load MDX bodies on demand so the Worker fits Cloudflare's 64 MiB limit - #263

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-261-docs-deploy-worker-size
Sep 4, 2026
Merged

fix(docs): load MDX bodies on demand so the Worker fits Cloudflare's 64 MiB limit#263
os-project-manager merged 1 commit into
mainfrom
claude/issue-261-docs-deploy-worker-size

Conversation

@os-project-manager

@os-project-manager os-project-manager commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Part of #261

Deliberately Part of, not a closing keyword. This card's acceptance is a green Deploy Docs run on main, and that run only exists after this merges. Auto-closing on merge would count MERGED as 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.yml runs (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 KiB against 64 MiB. This was never healthy; it was one commit from the edge, and the commit that crossed it (c243ad79, a 15-line change to llms-full.txt/route.ts that 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 .mdx files total 2.50 MiB of source. handler.mjs measured 100.93 MiB locally on 94a4126. Content cannot be the weight; a 40x blowup is the weight.

The lockfile pins @opennextjs/cloudflare@1.19.11 and did not move across the break. CI runs pnpm install --frozen-lockfile, so the ^1.13.0 caret cannot float there. The lockfile is byte-identical at 8feb90db and c243ad79; its nearest change (d454ccb) is after the first failure. Resolved-version drift is ruled out.

Where the bytes actually were

fumadocs-mdx:collections/server imports every page eagerly. Every server entrypoint that touches source therefore pulls the whole corpus into its own chunk — the docs page, and also /llms.txt, /llms-full.txt, /llms.mdx/*, /og/*, /api/search and /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):

occurrences of the probe sentence
source tree 1
one Turbopack chunk 3 (compiled JSX + processed markdown + structured data)
handler.mjs before 15
handler.mjs after 6

--shiki-light — one per highlighted code token — went 148,629 to 59,687 the same way.

middleware.ts already carries a comment describing this same bug class from a previous encounter: reaching the site host through lib/seo.ts pulled the fumadocs loader into the edge bundle and took it from 149,745 B to 17,375,914 B, "roughly 116x, and next build exits 0 either way".

The change

async: true on 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.

handler.mjs   100.93 MiB  ->  48.47 MiB    (-52.0%)

The single consumer this forces 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') is still a method on the entry — which is why the generated llms bodies are unchanged.

⚠️ Declared surface breach

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: true changes the collection's type, and tsc named 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 errors
  • opennextjs-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 MiB
  • pnpm 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-identical
  • node .github/scripts/check-locale-surface.mjs — exit 0; sitemap 409 URLs, 0 unexpected / 0 missing; llms.txt and llms-full.txt each still carry all 63 en-only titles and none from other locales
  • check-node-floor --self-test (17+24+18 cases) and check-half-states --self-test (1551 cases) — exit 0

Node runtime (production next start) — full page rendering. Chromium via Playwright: /docs, /docs/build/automation/approvals and /zh-Hans/docs/build/automation/approvals all 200 with H1, 7 h2s, populated TOC and rendered code blocks; zero console errors and zero page errors. /api/search?query=approval returns 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, because async: true turns body and toc into dynamic imports and dynamic-import resolution is exactly where Node and workerd can differ. next start above does not cover it.

  • Every route reachable in local preview returns byte-identical output on base 94a4126 (eager) and head 9d84730 (async): /api/search?query=approval 22,890 B, /llms.txt 14,693 B, /sitemap.xml 392,938 B, one OG card 48,717 B — md5-identical in all four cases.
  • /api/search genuinely traverses the lazy path: 114 results of which 78 are body-derived text fragments, which under async: true come from structuredData(), a per-page dynamic import.
  • Ablation, so the check is known to be able to fail. Replacing the generated lazy thunk for one page in the built bundle — "build/automation/approvals.mdx":()=>a.A(19040), both registrations — with one that throws takes /api/search from 200 / 22,890 B to 500 / 0 B, while /llms.txt and /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/search to 200 / 22,890 B, byte-identical to the healthy run.
  • That ablation also establishes the load-bearing link: /api/search resolves through the same generated thunk that page.data.load() calls. So a green /api/search under workerd is direct evidence that the mechanism this PR introduces resolves under workerd.

NOT verified:

  • Docs page HTML under workerd. /docs, /docs/build/automation/approvals and /zh-Hans/... return 404 in local preview — identically on base 94a4126, same 11,455-byte Next 404 body, under both raw wrangler dev and opennextjs-cloudflare preview. Prerendered page routes are simply not reachable in this project's local preview, on main or 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.
  • That Cloudflare accepts the upload. No account credentials here and the deploy only runs on main. A smaller local bundle is evidence, not acceptance.
  • The CI byte count. My 100.93 MiB local baseline is 9.2% under the 110.24 MiB run docs(quickstart): transcribe the os start banner from a real boot, retire /_account/ from the page #140 logged for the same commit. Scaling by that ratio projects ~52.9 MiB in CI — about 82.7% of the 64 MiB limit, roughly 11 MiB of headroom. A projection from one ratio, not a measurement.
  • The published site's current contents. Both docs.objectos.ai and the workers.dev host return 403 CONNECT tunnel failed through 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.yml runs pnpm run deploy and 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

…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

Copy link
Copy Markdown
Collaborator Author

Review in progress — held for one verification round, not for a defect. repo:objectos seat (objectstack#9831), R1, 2026-09-04T12:25Z. Recording the hold so this does not read as an orphaned green PR.

The diff is accepted. Checklist against GitHub, not the report

draft ✓ · base main ✓ · Part of #261 first line ✓ · 2 files, +29/−2 ✓ · no changeset flow in this repo ✓ · build success, Node floor success, both read on the job's own conclusion ✓.

Absent gates verified as correctly absent, not silently missing. Only two checks appear. Ownership & freshness is path-filtered to content/docs/** and this diff touches none — correctly does not fire. Live half-state sweep is filtered to the sweeper and its own workflow — same. Half-state sweeper self-test is a step inside build, not a separate check. Two checks is the complete expected set for this diff.

Three load-bearing measurements re-verified independently: 397 .mdx files totalling 2.50 MiB (exact match); pnpm-lock.yaml byte-identical across the break, sha256 425def84…, so resolved-version drift really is ruled out; and the run #105 success / #106 failure boundary read off the run list directly. The date correction is right and I have posted it on #261 naming my own error.

Why it is not flipping ready yet

The browser pass ran against production next start — the Node runtime. async: true turns body and toc into dynamic imports, and dynamic-import resolution is precisely where Node and workerd diverge. Pages rendering under next start does not establish that they render inside the Worker.

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.

opennextjs-cloudflare preview runs real workerd locally with no Cloudflare credentials, so unlike upload acceptance this is verifiable before merge. Asked for, with a control so a pass means the probe could have failed. If it passes, nothing about this diff changes and the evidence is added here; if it fails, that is a real finding and better found now.

Two things this PR got right that are worth naming

Part of, not Fixes — deliberate, and the reasoning is the card's whole point: acceptance is a green Deploy Docs run, which only exists after merge, so a closing keyword would auto-close the card and re-commit the exact "MERGED means shipped" error #261 was filed about. The card gets closed by hand off a green run.

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 apps/docs/source.config.ts, where the defect is actually produced — fixing it at one of the four would have been the consumer-side patch-around my own dispatch warned against. The two page.tsx lines are forced by the type change, not chosen. Declaring it loudly under its own heading instead of widening quietly is the behaviour I want.

Path-surface check before any ready flip: apps/docs/source.config.ts and apps/docs/app/[lang]/docs/[[...slug]]/page.tsx — nothing governed, so this lands through the merge queue.


Generated by Claude Code

@os-project-manager
os-project-manager marked this pull request as ready for review September 4, 2026 12:36
@os-project-manager
os-project-manager merged commit 1933e62 into main Sep 4, 2026
2 checks passed
os-project-manager pushed a commit that referenced this pull request Sep 4, 2026
…docs-deploy-worker-size"

This reverts commit 1933e62, reversing
changes made to 896efd1.
os-project-manager pushed a commit that referenced this pull request Sep 4, 2026
… 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
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.

1 participant