fix(docs): serve prerendered pages from the static-assets incremental cache, and shrink the Worker under the 64 MiB limit - #273
Merged
os-project-manager merged 4 commits intoSep 4, 2026
Conversation
… 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
Reverted in the next commit on this branch. Pushed as its own commit so the evidence and its removal are both on the record. `Package the Worker` and `Upload the Worker bundle` are push-only, so `opennextjs-cloudflare build --skipNextBuild` has never once executed with an incremental cache configured — its first run would be the merge commit. If that path does not produce or does not preserve `.open-next/cache`, the Worker deploys with the cache CONFIGURED and EMPTY: every lookup misses, `dynamicParams = false` refuses the on-demand render, and every page 404s. That is the outage this branch diagnoses, reproduced by its own fix. So the two steps gain a `pull_request` clause — the only difference from what ships — and a temporary job downloads the artifact and runs `opennextjs-cloudflare populateCache local`, which is exactly what `opennextjs-cloudflare deploy` runs before `wrangler deploy` and, for the static-assets cache, a filesystem copy needing no credentials. It then weighs the bundle with `wrangler deploy --dry-run`, which gives #262 a CI-measured number instead of one scaled from a local ratio. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GkauAsZBEemRbco2rEX9Lx
The evidence it existed to collect is on CI run 33889336935 and quoted in the PR body. `.github/workflows/ci.yml` is now byte-identical to f35234f, so this branch ships only the three `apps/docs/**` files. What the probe established, on CI's own `opennextjs-cloudflare build --skipNextBuild` output rather than a local combined build: cache present after --skipNextBuild yes (step exits non-zero if absent) artifact 93,597,106 B, sha256 7c354e53... cache survived the round-trip yes, with .open-next/.build/ intact populateCache local "Successfully populated static assets" prerendered routes 1139 servable from static assets 1139 missing 0 Total Upload (wrangler dry-run, CI) 58549.06 KiB = 89.34% of 65536 KiB Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GkauAsZBEemRbco2rEX9Lx
The permanent home of the check the TEMPORARY probe two commits ago ran once. Verified once is not verified. ## What it guards `apps/docs` serves every page from a prerendered entry in `.open-next/cache`, which `opennextjs-cloudflare deploy` copies into the uploaded assets. If those entries are absent, the Worker publishes with its cache CONFIGURED and EMPTY: every lookup misses, `dynamicParams = false` refuses the on-demand render, and every page 404s — while the deploy step exits 0, because the upload succeeded. `check-deploy-version.mjs` does not catch it either: a new version really is serving. It is just serving 404s. The cache is produced by a build invocation no pull request runs, travels to the deploy job as an artifact, and is copied again by the deploy command. Three places to lose it, none of which turn a step red on their own. ## Where it runs In the `deploy` job, after the artifact is downloaded and BEFORE the deploy step, so a bad bundle is refused rather than published and then reported. It asserts against Next's own `prerender-manifest.json` from inside the bundle — not a number anyone wrote down — so a page added to the corpus is covered the day it is added, and the check cannot pass by comparing a stale expectation to itself. ## Demonstrated able to fail, twice Fixtures: 8 cases covering all 7 rules; `run-self-tests.mjs` asserts every rule has a fixture that trips it, so weakening one exits 1. Registered there, which that runner independently enforces — an unlisted script declaring a `--self-test` fails it by name. Live, against a real 1139-route bundle: intact 1139 servable, 0 missing EXIT 0 mutated 1138 servable, 1 missing EXIT 1, naming /en/docs/quickstart restored 1139 servable, 0 missing EXIT 0 (md5 f465ea19, byte-identical) `shell: bash` on the step is load-bearing for the reason the verdict step below it already documents: the default shell has no pipefail, so `node ... | tee` would take tee's status and a gate exiting 1 would pass silently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GkauAsZBEemRbco2rEX9Lx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #261. The mechanism behind #265 is established and fixed here too — that card can be closed by hand once someone agrees with the account below; no closing keyword, deliberately.
⛔ This is not PR #263 re-applied. It contains PR #263's change, plus the thing that was actually broken, plus a gate so it cannot come back. The first section is the evidence that these are two different defects.
The diagnosis:
async: truedid not break renderingNobody had diagnosed the 2026-09-04 outage. The record said "PR #263 merged, upload accepted, site broke, rolled back", and the only account of why was a guess that the new thing was the cause.
It was not.
mainwas already unservable beforeasync: trueexisted on it, and had been for nine days.Run against real workerd (
opennextjs-cloudflare preview), using this repository's own.github/scripts/smoke-docs.mjs— the check that gates production, which asserts anh1with matching text, a visible-prose floor measured after script and style are stripped, a same-site link floor, the document language and the final path:smoke-docs.mjs/,/en/docs,/docs/quickstart,/docs/build/interface/viewsmain@d6f5dda, noasync: truemain+async: trueonly (= PR #263)The middle row is the exoneration. The failing tree in row 1 does not contain the change that was blamed and reverted.
Why every page 404s
Every page on this site lives under
app/[lang]/, so every page route is a dynamic route prerendered throughgenerateStaticParams()— 1139 paths in the prerender manifest.OpenNext runs Next in minimal mode: Next does not read prerendered HTML off a filesystem, it asks the configured incremental cache for it.
defineCloudflareConfig()called with no arguments resolvesincrementalCacheto"dummy", and the dummy cache'sget()throwsIgnorableError('"Dummy" cache does not cache anything')on every call. So the lookup for a prerendered page always misses.What happens after the miss is decided by one route-segment flag:
dynamicParamsunset (Next's default,true) — the miss falls through to an on-demand render. Every request re-renders the page, wastefully but correctly, and the site works.dynamicParams = false— Next refuses the on-demand render and raisesNoFallbackError, which OpenNext answers with the prerendered_not-foundroute. The page 404s.Observed directly in the workerd log on unmodified
main:and on the wire,
x-nextjs-prerender: 1withx-nextjs-cache: MISSand a 404 body.export const dynamicParams = falsewas added toapp/[lang]/layout.tsx,app/[lang]/docs/[[...slug]]/page.tsxandapp/og/docs/[...slug]/route.tsxon 2026-08-26, across five separate PRs about 404 semantics (#190, #192, #209, #211, #213), each correct in itself. The last accepted deploy was 2026-08-25 — the Worker went over the 64 MiB limit that evening and every upload afterwards was rejected. So the flag sat onmainfor nine days and never reached production.PR #263's merge produced the first accepted upload in nine days. It published nine days of merged work, one item of which turns every page into a 404.
Confirmed against the live site, which this container cannot reach, by dispatching the smoke workflow onto a runner (run
33886707832):/docs/quickstart?os261-cachebust=a1— a URL that has certainly never been requested before, so no CDN copy of it can exist — returns 200 withh1"Quickstart" and 9101 visible characters. The live Worker really does render on demand, because the version serving it (69c79ee3-..., from8feb90db) predatesdynamicParams = false. That is the whole reason the site is up.What the previous round's ablation could and could not show
The round-1 dev's own post-mortem on #261 is right, and this PR agrees with it from the other direction: breaking a lazy thunk and watching
/api/searchgo 200 → 500 proved module resolution, and/api/searchrenders no components, so it could never have failed on rendering. Every route that round measured byte-for-byte —/api/search,/llms.txt,/sitemap.xml,/og/*— reads page bodies without rendering them.None of those routes are used as rendering evidence here. The evidence above is rendered HTML judged by the production smoke check, plus a real browser (below).
The fix
staticAssetsIncrementalCachereads prerendered entries straight out of the Workers static assets this Worker already binds asASSETS, undercdn-cgi/_next_cache— a prefix only the Worker can reach.opennextjs-cloudflare deploycopies.open-next/cacheinto.open-next/assetsbefore uploading, andpreviewdoes the same locally.revalidate = falseon every route handler, no ISR, no on-demand revalidation.x-nextjs-cachegoes MISS → HIT on/docs/quickstart,/llms.txtand/sitemap.xml./cdn-cgi/_next_cache/.../quickstart.cachereturns 404 to a public request. The 277 MB of prerendered payloads are not publicly enumerable.A side effect worth naming: the Worker no longer renders a docs page at request time at all.
await page.data.load()now runs only at build time, in Node — which is where the previous round could verify it. Proved by ablation rather than asserted: corrupting one page's cache entry produced a 404, not an on-demand render.async: trueis restored for its own, separate reason — the size defect #261 was filed about. Without itfumadocs-mdxeagerly imports all 397.mdxfiles into every server entrypoint that touchessource, and the bundler inlines that set five times over.The gate that ships with it
This fix creates a new way to break the site silently, so it ships with the check for it. If
.open-next/cacheis ever absent or incomplete, the Worker publishes with its cache configured and empty: every lookup misses,dynamicParams = falserefuses the render, and every page 404s — while the deploy step exits 0, because the upload succeeded, and whilecheck-deploy-version.mjspasses, because a new version really is serving. It is just serving 404s..github/scripts/check-prerender-cache.mjsruns indeploy-docs.yml'sdeployjob, after the artifact download and before the deploy step, so a bad bundle is refused rather than published and then reported. It asserts against Next's ownprerender-manifest.jsonfrom inside the bundle, not against a number anyone wrote down, so a page added to the corpus is covered the day it is added.Demonstrated able to fail, twice. Fixtures: 8 cases covering all 7 rules, and the runner asserts every rule has a fixture that trips it, so weakening one exits 1. It is registered in
tools/ci-scripts/run-self-tests.mjs, which independently fails by name on an unlisted script that declares a--self-test. And live, against a real 1139-route bundle:CI's own build path, exercised rather than assumed
Package the WorkerandUpload the Worker bundleare push-only, soopennextjs-cloudflare build --skipNextBuildhad never once executed with an incremental cache configured — its first run would have been the merge commit. That is the same shape as the defect this PR diagnoses, so it was measured instead of reasoned about: commitfb7259btemporarily added apull_requestclause and a probe job, and3e037d6reverted it, leaving.github/workflows/ci.ymlbyte-identical to before.What CI run
33889336935produced, on its own split-invocation artifact:The probe's own check carried a failure branch (
::error:: N route(s) would 404 in production,exit 1) — it is the same code now shipping as the permanent gate. CI on the final headee99ae5is green with those stepsskippedagain, which is what confirms the revert took.Size, measured rather than projected
PR #263 projected ~52.9 MiB / 82.7% by scaling one local-versus-CI ratio on
handler.mjs, and CI then measured 58541.00 KiB / 89.3%.Total Uploadcovers more than that one file, so this PR measures the bundle wrangler would actually upload.async: true, measured in CI)wrangler deploy --dry-runlocallywrangler deploy --dry-runin CI on the real artifactThe local dry-run and CI's agree to within 1.98 KiB, so the local figure was not a projection standing in for a measurement.
handler.mjs: 105,808,656 B → 50,827,882 B — 100.91 MiB to 48.47 MiB, a 52.0% cut. Note how little of that reachesTotal Upload: 52.4 MiB comes off one file and the upload moves ~4 MiB. ⛔ Do not quote the −52% as the deploy's headroom; that is the error PR #263's projection made.The ceiling is settled: 65536 KiB. #261 used both 64 MB and 64 MiB in one card, 1.5 MiB apart. Run #140's own rejection text reads "Your Worker exceeded the uncompressed size limit of 64 MiB" — Cloudflare's wording, so the denominator is 65536 KiB, this PR is at 89.34%, and headroom is 6986.94 KiB / 6.82 MiB. #262 can set its budget from that without re-deriving it.
Static assets are uploaded separately and do not count toward the Worker limit. The asset store goes from 35 files to 1697 (2260 as wrangler counts them) against a 20,000-file cap; largest single asset 795 KB against a 25 MiB cap.
Verified BEFORE merge
Everything below ran against
opennextjs-cloudflare preview— real workerd, no Cloudflare credentials — on the built bundle, notnext start.smoke-docs.mjsgreen against local workerd, with its live negative control still going red in the same run. This is the first time this repository's rendering check has been run against a real Workers render before a merge; that is Prerendered docs page routes 404 under localwrangler dev/opennextjs-cloudflare preview, so no page can be smoke-tested in the Workers runtime before merge #265's ask.langand localized headings:/zh-Hans/docs/quickstart快速开始,/zh-Hant/docs/quickstart快速開始, plusja,de,es,fr,ko, each with a populated table of contents.h1./docs/build/automation/approvals:h1"Approvals", 7h2, 18 table-of-contents anchors, 22 sidebar links, 6 rendered code blocks, 13558 visible characters. The same probe against the negative-control path returns the 404 shell — 138 visible characters,h1"404" — so the browser check demonstrably discriminates.--skipNextBuildpath and the artifact round-trip, on a runner, with the numbers in the section above./api/search?query=approval22890 B,/llms.txt14693 B,/sitemap.xml392938 B,/docs/quickstart.mdx11909 B, one OG card 60871 B./cn/...308,/EN/docs308,/zh-hant/...308./docs/quickstartfrom 200/178884 B to 404/11462 B while/docs/build/interface/viewsand/llms.txtstayed at their exact byte counts; md5 restore returned it to 200/178884 B. Mutation was confirmed on disk before any reading was taken.type-check0 ·turbo run test --force0 ·check-locale-surface0 (sitemap 409 URLs, 0 unexpected, 0 missing) ·gen-zh-hant --check0 (73 files byte-identical) ·check-node-floor0 ·smoke-docs --self-test0 ·check-prerender-cache --self-test0 ·run-self-tests.mjs0 (7 self-tests) ·check-half-states --self-test0 (1551 cases). CI green on the final headee99ae5(run33890142955).NOT verified before merge, and cannot be
main.403 CONNECT tunnel failedfor bothdocs.objectos.aiand theworkers.devhost. Local workerd is the same bundle and the sameASSETSmechanism, but it is not the live edge.The third item this list carried on the first draft — CI's own split build invocation — is no longer on it; it was measured, above.
⛔ The #269 verification layer is the backstop for 1 and 2, not the verification. It has never executed the chain deploy succeeds → smoke fails → rollback fires, and this PR is not the place to find out whether it works. What is different from the last attempt is that the assertion that failed last time — a docs page rendering under workerd — is no longer on this list.
Deviations
async: truealone would keep the site 404ing; the cache alone would keep the upload rejected. Neither ships anything on its own, and separating them would mean merging one known-broken deploy on purpose. The gate is here because the fix creates the failure mode it guards..githubandtools/ci-scriptsare outside the dispatchedapps/docssurface. Declared rather than widened quietly. The temporary CI probe and the permanent gate were both asked for during review, after theapps/docschange was accepted.fb7259badds it,3e037d6reverts it, andgit diff f35234f 3e037d6 -- .github/workflows/ci.ymlis empty. Its evidence lives on CI run33889336935.Part of #261, not a closing keyword. Acceptance is a greenDeploy Docswith a new version id and a green smoke check on the live site — both only exist after merge.33886707832;workflow_dispatchcannot deploy, andfile_issuedefaults to false, so no card was filed). It showsfailure, and the only findings arefinal-pathon the two deliberately cache-busted probe URLs — the query string is not part of the final path. Both probes returned 200 and rendered; that run is the live-site evidence quoted above, not a regression.Authorship
Written by Claude Code (session
01GkauAsZBEemRbco2rEX9Lx), https://claude.ai/code/session_01GkauAsZBEemRbco2rEX9Lx — as prose rather than a trailing footer block, because a PATCH edit of a pull-request body drops that block: the one this PR was created with did not survive the update that added the sections above. Recorded once, here, rather than re-posted on every edit.