Skip to content

perf(cloudflare): serve static App Router HTML and RSC from Cloudflare Assets, bypassing the Worker#2506

Open
NathanDrake2406 wants to merge 32 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/static-artifact-worker-bypass
Open

perf(cloudflare): serve static App Router HTML and RSC from Cloudflare Assets, bypassing the Worker#2506
NathanDrake2406 wants to merge 32 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/static-artifact-worker-bypass

Conversation

@NathanDrake2406

@NathanDrake2406 NathanDrake2406 commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Overview

Field Details
Goal Let fully static App Router document and plain RSC requests avoid the Worker App Router render lifecycle on Cloudflare.
Core change Publish eligible HTML at visible asset paths, publish proven-static RSC under a reserved static transport path, and route variant RSC through a Worker-only transport fallback.
Boundary Cloudflare Workers builds only, gated by assets config and prerender query-invariance proof. Middleware, config transforms, ISR, fallback shells, basePath, i18n, and trailingSlash stay Worker-owned.
Primary files packages/vinext/src/build/cloudflare-prerender-assets.ts, packages/vinext/src/build/cloudflare-static-assets-config.ts, packages/vinext/src/server/app-rsc-transport.ts, packages/vinext/src/server/app-rsc-cache-busting.ts, packages/vinext/src/server/app-router-entry.ts
Expected impact Static document requests use Cloudflare Assets before Worker invocation. Plain static RSC requests use a reserved static asset path. Variant RSC keeps existing Worker semantics.

Follow-up review fixes

Since the initial description, these correctness fixes were added:

Area Issue Fix
_headers rule limit HTML routes emit one _headers rule each; a large static app could exceed Cloudflare's 100-rule limit and produce an invalid asset bundle. Publisher plans first, budgets preserved + generated rules, and skips publication entirely (no files written) when it would overflow.
Deploy env runPrerender() ran outside withCloudflareEnv, so the publisher could read an undefined CLOUDFLARE_ENV and disagree with the build's selected environment. Wrapped runPrerender() in withCloudflareEnv(buildEnv).
Idempotency A repeated prerender (e.g. deploy --skip-build) saw its own output as a collision, planned nothing, and dropped the generated _headers block — leaving assets served without headers. The generated block is the ownership manifest: owned targets are re-published, foreign targets still skipped.
Stale bodies Owned assets kept their headers but not their contents when the prerender output changed. publishAsset overwrites owned/fresh targets with the current source via temp-file + rename (atomic).
Token canonicality Forgiving base64 accepted many spellings of one route token (padding bits, = padding, +// alphabet), and invalid UTF-8 collapsed distinct byte sequences into replacement-character routes. Decoder accepts only the canonical base64url spelling (re-encode must round-trip) and decodes with TextDecoder("utf-8", { fatal: true }).
URL-shaped tokens A decoded token containing ?/# smuggled query/fragment syntax when concatenated into the mapped URL. resolveRscTransportRequest assigns mappedUrl.pathname instead of string-concatenating, and decoded tokens must pass pathname validation.
URL rewrite bypass The validator still accepted characters the URL implementation rewrites on url.pathname assignment: /foo\bar/foo/bar, /\evil.com//evil.com, newlines stripped, /foo/../bar/bar — breaking the bijective route-token invariant. A transport-local isUrlSerializedPathname requires an exact URL serialization round-trip (new URL(value, base).pathname === value), rejecting anything the pathname setter would rewrite. The shared interception matched-url validator keeps its loose semantics — interception proofs legitimately carry decoded pathnames.
Server action POST collision Server actions POST to the visible page path (fetch client and no-JS form fallback). Cloudflare's asset worker claims a published asset path for every method — getIntent runs before the method check, so POST /about against a published HTML asset returns 405 from the asset worker and never invokes the Worker (confirmed in the workers-shared asset-worker source). The rsc build records hasServerActions in vinext-server.json; the publisher skips visible HTML publication when actions exist (or the flag is absent), keeps the GET-only reserved static RSC transport, and prunes previously published HTML on a repeat prerender.
Hybrid build flag loss Hybrid CLI builds run the Pages SSR bundle as a second builder with a separate vinext() closure; its ssr env wrote vinext-server.json last and dropped hasServerActions (secret-keyed preservation never matched across builders), so the publisher skipped visible HTML for every hybrid CLI build. The ssr environment preserves an existing flag unconditionally (Pages Router has no server actions of its own). Caught by the local benchmark's sanity gate; regression-tested by driving the real plugin's writeBundle.
Ancestor/descendant path conflict /blog needs dist/client/blog as a file while /blog/post needs it as a directory; the plan phase checked targets independently, so publishing both aborted the prerender (ENOTDIR or EISDIR depending on route order). Planning demotes a route whose HTML target is the directory prefix of another planned HTML target (Worker keeps serving it; flat tokenised RSC transport unaffected), a directory at the target is no longer a foreign-file collision, pruning removes emptied directories so ancestors can publish again, and publishAsset degrades to "not published" on directory-shape conflicts instead of aborting.
Foreign parent file A pre-existing user asset file (dist/client/blog) blocks the parent directory a descendant target needs (dist/client/blog/post); the exact-target foreign check missed it, mkdirSync threw ENOTDIR, and the catch block's temp-file rmSync threw ENOTDIR itself before the fallback could run — aborting prerender. publishAsset temp-file cleanup is best-effort and EEXIST (the Windows mkdir error for this shape) joins the safe "not published" codes; the route degrades to Worker rendering with transport RSC still published and the user asset untouched.
output: "export" transport mismatch The client transport define only checked "Cloudflare plugin + assets config allows fallback". Export builds emit plain about.rsc next to the HTML and the publisher skips transport publication, so the browser requested reserved transport assets that were never emitted. The define additionally requires nextConfig.output !== "export"; export clients keep requesting RSC at public route URLs. Test proves the gate flips on the same root.

Also deduped: shared utils/jsonc (JSONC parsing) and resolveWranglerJsonPath, previously copied across init-cloudflare and the assets-config reader.

Why

Cloudflare Assets matches by path before the Worker can inspect RSC headers. A public HTML asset at /about is therefore incompatible with browser RSC requests that also use /about?_rsc=.... Correct static publication needs three distinct resources: the public HTML document, the static RSC artifact, and dynamic or variant RSC requests. This PR splits those transports instead of trying to make one public pathname represent all three.

Area Principle / invariant What this PR changes
Document HTML Fully static, request-invariant HTML should not pay App Router render lifecycle cost. Copies eligible HTML to visible Cloudflare asset paths such as /about.
Browser RSC Browser RSC requests must not be shadowed by visible HTML assets. Changes Cloudflare client RSC URLs to a reserved static namespace for plain proven-static RSC.
Variant RSC Prefetch, mounted slots, interception, state-tree, refresh, and reuse-manifest variants are not the same payload. Sends variant RSC to a reserved Worker namespace that maps back to the visible route before dispatch.
Safety gates Request-observable behavior must stay Worker-owned. Skips static publication for middleware/proxy, config transforms, basePath, i18n, trailingSlash, ISR, fallback, /404, and /500.Visible HTML additionally requires the build to contain no server actions.
Cache proof Query-bearing requests are only safe when prerender proved search params were not observed. Requires queryInvariant.html === true for HTML and queryInvariant.rsc === true for static RSC.
Cloudflare config Missing transport assets must fall through to the Worker. Enables the browser transport only for Cloudflare builds whose wrangler assets config is absent or uses not_found_handling: "none".Disabled entirely for output: "export" builds, which never emit transport assets.

What changed

Scenario Before After
Static document /about Worker entered App Router dispatch/render/cache lifecycle. Cloudflare Assets can serve dist/client/about directly.
Plain RSC navigation for /about Browser requested /about?_rsc=..., which collides with a visible HTML asset. Browser requests /_next/static/__vinext/prerendered-rsc/about.rsc?_rsc.
Variant RSC request Same public route URL with variant headers. Browser requests /__vinext/rsc/about.rsc?_rsc=...; Worker maps it back to /about before dispatch.
Stale transport _rsc hash Could redirect back to the public route URL. Canonical redirect remains on the transport path.
Existing asset collision Existing target stayed untouched. Route publication is skipped when the visible HTML target or reserved RSC target already exists.

Benchmark

Local paired Workers benchmark (script kept out of the PR diff; two concurrent wrangler dev servers kept alive, samples alternate AB/BA per round, sanity-gated: head must serve /about with x-vinext-cache: STATIC, base must not):

  • Fixture: tests/fixtures/cf-app-basic, copied to a temp dir with middleware.ts removed so middleware cannot observe requests.
  • Build: node packages/vinext/dist/cli.js build --prerender-all (the real CLI path).
  • head: generated build with published visible HTML and reserved static RSC assets.
  • base: byte-identical copy with the published route assets stripped (using the generated _headers block as the manifest), so requests fall through to the Worker.
  • Method: 8 paired AB/BA rounds, 250 warmup requests per sample excluded, 1600 measured requests per sample, concurrency 8, Node fetch() latency including body consumption.
  • Latest run (branch head, 2026-07-04): benchmarks/results/static-artifact-worker-bypass/2026-07-04T08-43-32Z/summary.md in the local ignored benchmark output.
scenario base mean ms head mean ms diff ms diff %
document-about 8.1366 4.6565 -3.4801 -42.77%
rsc-about-static-transport 8.4376 4.2110 -4.2266 -50.09%
document-blog 8.3508 4.5483 -3.8025 -45.53%
rsc-blog-static-transport 9.0687 4.2572 -4.8116 -53.06%
route-api-control 4.3568 4.3278 -0.0290 -0.67%

The control route is effectively neutral. The useful signal is that static document and static RSC requests leave the App Router lifecycle rather than shaving a small allocation inside it. (An earlier ad-hoc run on the pre-review head showed the same shape: -51%/-36%/-50%/-32%, control +0.15%.)

Maintainer review path

Suggested order
  1. packages/vinext/src/server/app-rsc-transport.ts
    • Review transport path encoding, plain RSC eligibility, and reverse mapping.
  2. packages/vinext/src/server/app-rsc-cache-busting.ts
    • Review the Cloudflare-only switch from public route _rsc URLs to split transport URLs.
  3. packages/vinext/src/server/app-router-entry.ts
    • Review Worker transport mapping and stale _rsc redirect handling before normal App Router dispatch.
  4. packages/vinext/src/build/cloudflare-prerender-assets.ts
    • Review publication gates, visible HTML publication, reserved static RSC paths, and _headers output.
  5. packages/vinext/src/index.ts
    • Review the client define gate for Cloudflare transport enablement.
  6. tests/app-rsc-cache-busting.test.ts and tests/cloudflare-prerender-assets.test.ts
    • Review the transport URL, redirect, publication, and collision regressions.

Validation

Commands and probes
  • vp fmt packages/vinext/src/build/cloudflare-prerender-assets.ts packages/vinext/src/build/run-prerender.ts packages/vinext/src/index.ts packages/vinext/src/server/app-router-entry.ts packages/vinext/src/server/app-rsc-cache-busting.ts packages/vinext/src/server/app-rsc-transport.ts tests/app-rsc-cache-busting.test.ts tests/cloudflare-prerender-assets.test.ts
  • vp check packages/vinext/src/build/cloudflare-prerender-assets.ts packages/vinext/src/build/run-prerender.ts packages/vinext/src/index.ts packages/vinext/src/server/app-router-entry.ts packages/vinext/src/server/app-rsc-cache-busting.ts packages/vinext/src/server/app-rsc-transport.ts tests/app-rsc-cache-busting.test.ts tests/cloudflare-prerender-assets.test.ts
  • vp test run tests/app-rsc-cache-busting.test.ts tests/cloudflare-prerender-assets.test.ts
  • vp test run tests/cloudflare-rsc-transport-config.test.ts tests/deploy-prerender-config.test.ts
  • vp test run tests/prerender.test.ts -t "Cloudflare Workers hybrid build"
  • Server-action gating tests: publisher keeps transport RSC but skips visible HTML when hasServerActions is set, repeat prerender prunes previously published HTML, and the hybrid Cloudflare build records the flag in vinext-server.json.
  • Follow-up regression tests: _headers rule-limit skip, idempotent re-run preserves the generated block, owned-asset overwrite when prerender output changes, and deploy sets CLOUDFLARE_ENV during prerender.
  • vp test run tests/app-rsc-transport.test.ts — token round-trip/uniqueness plus rejection of non-canonical base64url, invalid UTF-8, unrooted pathnames, query/fragment syntax, and URL-rewritable tokens (backslash, protocol-relative, newline, dot segments).
  • vp run vinext#build
  • Fresh no-middleware tests/fixtures/cf-app-basic build with node packages/vinext/dist/cli.js build --prerender-all
  • Local wrangler dev probes verified:
    • /about returns static HTML with x-vinext-cache: STATIC.
    • /_next/static/__vinext/prerendered-rsc/about.rsc?_rsc returns text/x-component with static cache headers.
    • /__vinext/rsc/about.rsc?_rsc=stale redirects to /__vinext/rsc/about.rsc?_rsc=<hash>, then returns text/x-component through the Worker path.
  • Pre-commit hook passed staged checks, full check, staged unit/integration tests, and Knip.
  • Local paired benchmark described above.

Risk / compatibility

Compatibility notes
  • Public API: no API changes.
  • Runtime scope: Cloudflare App Router entry maps reserved Worker RSC transport requests back to visible routes before dispatch.
  • Client scope: Cloudflare builds use split RSC URLs only when the wrangler asset config can fall through to Worker on misses.
  • Build output: eligible HTML is published at visible paths; eligible RSC is published under /_next/static/__vinext/prerendered-rsc.
  • Existing apps with middleware/proxy, config headers/redirects/rewrites, basePath, i18n, trailingSlash, ISR, fallback shell, /404, or /500 stay Worker-owned.
  • The main review risk is whether another RSC variant header should force Worker transport. Current gating covers prefetch, segment prefetch, state tree, next URL, reuse manifest, interception context, mounted slots, and non-navigation render modes.

Non-goals

Out of scope
  • No route-handler fast path.
  • No server action optimization.
  • No lazy cookies, headers, or request-store changes.
  • No ISR asset publication.
  • No middleware or config transform bypass.
  • No cache lifecycle refactor.

Fully static App Router prerender outputs currently stay server-owned, so Workers still execute the App Router request lifecycle for cache-hit document and RSC responses. That is unnecessary when Cloudflare Assets can represent the response and no middleware, config transforms, slash redirect, basePath, or i18n routing can observe the request.

Add a conservative build-time publisher that copies eligible static App Router HTML/RSC artifacts into the Cloudflare assets directory and emits matching _headers. Skip ISR, Pages routes, /404 and /500 status routes, existing asset collisions, middleware/proxy projects, and request-transforming config.

Tests cover publication, generated headers, middleware/config opt-outs, ISR/pages/status-route skips, and asset-collision skips.
@pkg-pr-new

pkg-pr-new Bot commented Jul 3, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/cloudflare/vinext/@vinext/cloudflare@2506
npm i https://pkg.pr.new/cloudflare/vinext/create-vinext-app@2506
npm i https://pkg.pr.new/cloudflare/vinext@2506

commit: b365aea

@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared b365aea against base 54497d4 using alternating same-runner rounds. Next.js was unchanged and skipped.

1 improved · 0 regressed · 5 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 126.4 KB 126.7 KB ⚫ +0.2%
Client entry size (gzip) vinext 120.5 KB 120.7 KB ⚫ +0.2%
Dev server cold start vinext 2.58 s 2.59 s ⚫ +0.4%
Production build time vinext 3.28 s 3.12 s 🟢 -4.8%
RSC entry closure size (gzip) vinext 98.0 KB 98.4 KB ⚫ +0.3%
Server bundle size (gzip) vinext 164.5 KB 165.1 KB ⚫ +0.4%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

NathanDrake2406 and others added 4 commits July 4, 2026 00:42
Cloudflare asset publication for prerendered App Router routes could publish static HTML and RSC files from route metadata alone. That bypassed the runtime cache guard that only serves query-bearing requests when render observation proves searchParams were not observed.

The publisher now requires prerender query-invariance metadata before exposing static App assets. Prerender records the proof using the same render observation check as the runtime cache path, and RSC assets require their own proof bit.
The request-pipeline test hard-codes the Vinext-only internal header set. The new prerender query-invariance side-channel is intentionally internal, so CI failed until the test covered that header as stripped and separate from Next.js INTERNAL_HEADERS.
Cloudflare asset publication could still publish generated HTML when a matching RSC asset already existed and generated RSC publication was disabled by query proof. That left Cloudflare serving mixed route semantics: generated HTML for the document request and an unrelated existing asset for the RSC request.

The route collision guard now treats any existing RSC target as a route-level collision, independent of whether generated RSC will be copied. The regression test pins the html-proof true, rsc-proof false case with an existing about.rsc asset.
Static App Router HTML assets can bypass the Worker on Cloudflare, but browser RSC requests cannot share the public document URL because Assets matches before the Worker sees RSC headers. Publishing document HTML at the visible route path without a separate RSC transport lets /about?_rsc collide with /about.

Route RSC requests through a Cloudflare-only split transport: plain proven-static RSC uses a reserved static asset namespace, variant RSC uses a Worker-owned namespace that maps back to the visible route before dispatch, and publication stays gated by query proof plus request-transform safety checks.

Tests cover Cloudflare transport URL selection, stale transport redirect canonicalisation, reserved RSC publication, and collision safety.
@NathanDrake2406 NathanDrake2406 changed the title perf(build): publish static app prerenders as Cloudflare assets perf(cloudflare): split static App Router asset transport Jul 3, 2026
…aders

Static App Router asset publication emitted one _headers rule per HTML and RSC artifact and enabled the client transport from root Wrangler config only. On Workers Static Assets, matching header rules inherit and duplicate values are joined, and selected environments can change assets.not_found_handling.

That made broad user rules able to corrupt static RSC cache headers, pushed publishable apps toward Cloudflare's header-rule limit, and allowed client RSC transport generation to disagree with emitted asset publication.

Resolve Wrangler assets through a shared CLOUDFLARE_ENV-aware helper, fail publication closed when the selected environment disables the transport, collapse static RSC protocol headers to one splat rule, and detach inherited protocol headers before setting generated values.
NathanDrake2406 and others added 9 commits July 4, 2026 02:45
…imit

publishCloudflarePrerenderedAppAssets emitted one _headers rule per
published HTML route plus a shared RSC wildcard, with no upper bound.
Cloudflare Static Assets rejects a _headers file above 100 rules, so a
fully static app with ~100 eligible routes — or fewer alongside existing
user rules — produced an invalid asset bundle at deploy time, exactly the
workload this optimisation targets.

The copy loop discovered rule counts only while writing files, too late to
back out. Split it into plan -> preflight -> execute: build the publication
plan without side effects, budget preserved user rules plus generated rules
against the 100-rule limit, and skip the whole publication with a clear
reason when it would overflow. No files are copied unless the resulting
_headers file is valid.

Also wrap runPrerender() in withCloudflareEnv(buildEnv) during deploy so the
publisher reads the selected environment's Wrangler assets config instead of
an undefined CLOUDFLARE_ENV, and dedupe the JSONC/Wrangler parsing that was
copied across init-cloudflare and the assets-config reader into shared
utils/jsonc and resolveWranglerJsonPath helpers.

Tests: publication skipped when 100 user rules leave no budget; deploy runs
prerender with CLOUDFLARE_ENV set to the deploy environment.

# Conflicts:
#	tests/cloudflare-prerender-assets.test.ts
…ated runs

publishCloudflarePrerenderedAppAssets treated any pre-existing target as a
collision and skipped the route, then rebuilt the generated _headers block
only from planned routes. A second prerender against the same dist/client —
e.g. `vinext-cloudflare deploy --skip-build`, which still runs prerender —
saw its own previously copied HTML/RSC assets, planned nothing, and stripped
the generated _headers block. The static files stayed on disk but lost the
Content-Type, Cache-Control, RSC-compatibility, deployment, and cache-state
headers that make them equivalent to the Worker responses being bypassed.

fs.existsSync(target) cannot tell a user asset apart from vinext's own prior
output. The generated _headers block already records what vinext published,
so use it as the ownership manifest: an existing target recorded there is
ours to re-publish, an existing target absent from it is a foreign collision
and is skipped as before. HTML rules are matched per route; static RSC lives
in a reserved transport namespace, so the wildcard rule's presence proves
ownership of every RSC target. Header rules are now re-emitted for owned
assets even when nothing is copied, so a repeated run preserves the block.

Test: publishing twice on the same output copies nothing the second time yet
leaves the generated block and its assets' headers intact. The existing
foreign-collision tests (user HTML asset, pre-existing reserved RSC target
with no generated block) still skip.
The idempotency fix preserved the generated _headers block for owned assets
but copied their bodies with copyIfAbsent, which returns "target-exists" and
leaves the existing file. A repeated prerender that produced different output
for a route — from changed data, env, build metadata, or time-sensitive
static generation — kept the stale asset in dist/client while advertising it
as fresh, so the served body no longer matched the current prerender.

Foreign-collision detection already happens in the plan phase, so every
target reaching publication is either absent or owned. Replace copyIfAbsent
with publishAsset, which always writes the current source over the target via
a temp file + rename (atomic, no partial asset on crash). Owned targets are
now refreshed, not just re-headered.

Test: after the first run writes "about-v1", changing the prerender source to
"about-v2" and running again updates both the HTML and static RSC targets.
The repeat-run test now asserts the second run republishes (publishedFiles 2)
rather than skipping the copy.
…rangler configs

Hard-navigation recovery (bad RSC payloads, compatibility mismatches,
redirect hops) derived browser-visible targets from RSC response URLs by
stripping only _rsc and a .rsc suffix. With Cloudflare RSC transport
enabled, response URLs live under /_next/static/__vinext/prerendered-rsc
or /__vinext/rsc, so a compatibility mismatch hard-navigated the user to
the internal transport path instead of the visible route.

The client transport flag also treated a root wrangler.toml or
cloudflare.config.ts as "no config": resolveWranglerJsonPath() only finds
JSON/JSONC, and an absent config counted as allowed. A project with
non-"none" not_found_handling in TOML could ship a client bundle emitting
transport URLs the publisher never proved servable.

Map transport pathnames back to visible routes inside
resolveHardNavigationTargetFromRscResponse(), which all three recovery
consumers share, and make readRootWranglerAssetsConfig() report
unsupported config formats as unreadable so both the client define and
the asset publisher fail closed.

Tests: transport response URLs hard-navigate to visible routes (static,
Worker, root file, trailing slash); wrangler.toml and cloudflare.config.ts
projects define the client transport flag as "false"; unparsable root
configs read as not ok.
Transport asset names encoded route structure with sentinel filenames:
/ mapped to /__root.rsc, trailing-slash routes to <route>/__index.rsc,
and other routes to <route>.rsc. That mapping is not injective — legal
routes like /__root and /docs/__index produce the same transport asset
pathnames as / and /docs/, so with Cloudflare RSC transport enabled the
resolver silently aliased those user routes to the internal sentinels,
corrupting both static RSC publication targets and Worker fallback
dispatch.

A route-to-asset mapping shared by the browser, the asset publisher, and
the Worker resolver must be bijective for every legal pathname; sentinel
filenames reserve ordinary-looking user path segments without rejecting
or escaping them.

Encode the full visible pathname as one opaque unpadded base64url token
(/_next/static/__vinext/prerendered-rsc/<token>.rsc, /__vinext/rsc/
<token>.rsc). The resolver accepts only canonical single-segment tokens
that decode to a rooted pathname. The base64url helpers move to
utils/base64url.ts, shared with the RSC cache-busting hash instead of
duplicating the encoder.

Tests: round-trip through both transport prefixes and distinctness for
/, /__root, /docs/, /docs/__index, /foo.rsc, /a%2Fb; pinned hand-derived
tokens so the wire format cannot drift silently; resolver rejection of
non-canonical, nested, empty, and non-rooted tokens.
…stem cap

publishAsset copies prerender output without guarding filename length.
Common filesystems cap a filename at 255 bytes, and base64url route
tokens grow the visible pathname by 4/3, so a route pathname over ~175
bytes makes the RSC asset copy throw ENAMETOOLONG and crash the whole
build. Very long final HTML segments hit the same wall.

The publish plan assumed every eligible route maps to a writable asset
filename. Guard each asset at planning time instead: HTML and RSC
publication independently skip targets whose filename (plus the
.vinext-tmp-<pid> sibling suffix) cannot fit, and the Worker serves the
route at runtime because the static request falls through on asset miss.

Test reproduces the ENAMETOOLONG crash with a 200-byte route segment and
verifies the long route still publishes its HTML while a normal route
publishes both assets.
decodeRouteToken validated token characters with a regex and then decoded
via atob. Forgiving base64 tolerates nonzero trailing padding bits and =
padding, so Lx, Ly, Lz, and Lw== all decode to the same byte as the
canonical Lw ("/"). One route therefore had many accepted transport asset
paths, breaking the one-route-one-URL invariant the transport
canonicalization relies on. The non-fatal TextDecoder similarly collapsed
distinct invalid-UTF-8 byte sequences into the same
replacement-character route.

Character-class validation cannot prove canonicality; only re-encoding
can. Require encodeBase64Url(decodeBase64Url(token)) === token, decode
with a fatal UTF-8 TextDecoder, and drop the now-redundant regex guard.

Tests cover the Lx/Ly/Lz/Lw== aliases of Lw and a canonical token whose
bytes are invalid UTF-8 (L_8 = 0x2F 0xFF).
@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review July 3, 2026 17:41
@NathanDrake2406 NathanDrake2406 marked this pull request as draft July 3, 2026 18:26
NathanDrake2406 and others added 6 commits July 4, 2026 16:48
… rewrite

isSameOriginPathname rejected ?, #, NUL, and leading //, but still accepted
raw backslashes, control characters, whitespace, raw non-ASCII, and dot
segments. resolveRscTransportRequest assigns the decoded token to
mappedUrl.pathname, where the URL implementation rewrites those values: a
token for "/foo\bar" becomes "/foo/bar", "/\evil.com" becomes "//evil.com",
newlines are stripped, and "/foo/../bar" resolves to "/bar". That breaks the
opaque bijective route-token invariant — two distinct tokens can map to the
same request pathname.

The validator checked a blacklist of known-bad characters instead of the
actual invariant: the value must already be in URL-serialized form.
Fix: require an exact URL serialization round-trip — parsing the value as a
path and reading the pathname back must be the identity — so anything the
pathname setter would rewrite is rejected before it reaches the URL.

Tests cover backslash, protocol-relative, newline, and dot-segment tokens.
…nsport

The previous fix tightened the shared interception matched-url validator to
require an exact URL serialization round-trip. That validator also guards
interception proofs and wire payloads, whose pathnames are legitimately in
decoded form (e.g. "/café"), so CI unit tests for route-state normalization
and ISR variant keying started rejecting valid payloads.

The strict round-trip invariant only belongs to the transport boundary,
where route pathnames come from url.pathname and are assigned back into
mappedUrl.pathname. Restore isInterceptionMatchedUrlPath with its original
loose semantics (revert the isSameOriginPathname rename) and move the
round-trip check into app-rsc-transport as a private isUrlSerializedPathname
used by the token encoder and decoder.
…r actions

Static publication copies prerendered HTML to visible asset paths like
/about. Server actions POST to that same visible page path — both the fetch
action client and the progressive no-JS form fallback — and Cloudflare's
asset worker claims a published asset path for every method: getIntent runs
before the method check, so a non-GET/HEAD request to a published asset
returns 405 from the asset worker and never invokes the user Worker
(confirmed in workers-shared asset-worker source). Publishing HTML for an
app with actions therefore breaks every server action on published routes.

The publisher assumed asset routing was method-aware; it is not. Fix: the
rsc build environment records hasServerActions in vinext-server.json (the
ssr environment preserves a same-build flag, keyed by the shared prerender
secret, so a stale flag cannot leak across builds), and the publisher skips
visible HTML publication when actions exist or the flag is absent. The
reserved static RSC transport path stays published — it is only ever
fetched with GET. A repeat prerender prunes previously published HTML when
a build gains actions.

Tests: publisher skips HTML but keeps transport RSC with actions present,
repeat-prerender pruning, and the hybrid Cloudflare build records the flag.
@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review July 4, 2026 08:34
…build

Hybrid CLI builds run the Pages Router SSR bundle as a second builder with
a separate vinext() closure (cli.ts). Its ssr environment writes
vinext-server.json after the App Router build recorded hasServerActions,
and the previous code only preserved the flag when the existing manifest
carried the same prerender secret — which is never true across builders.
The flag was silently dropped, the prerender publisher treated absence as
"actions may exist", and visible HTML publication was skipped for every
hybrid CLI build, disabling the static document fast path the PR exists
to provide.

The preservation guard assumed all manifest writers share one plugin
closure; the hybrid CLI build does not. Pages Router has no server actions
of its own, so the ssr environment now preserves an existing flag
unconditionally and still omits it when there is nothing to preserve.

Also adds benchmarks/perf/static-artifact-worker-bypass.mjs, a rerunnable
paired benchmark (two concurrent wrangler dev servers, AB/BA rounds) whose
sanity gate — head must serve /about as a published static asset — is what
caught this regression.
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

…descendant routes

A route's visible HTML target is a plain file (/blog -> dist/client/blog)
while a descendant route needs that same path as a directory
(/blog/post -> dist/client/blog/post). The plan phase checked each target
independently, so a static app containing both routes queued both and the
copy phase aborted the prerender: mkdirSync failed with ENOTDIR after the
ancestor was written as a file, or renameSync failed with EISDIR after the
descendant created the directory — depending on route order.

The planner assumed visible asset targets are independent; sibling file and
directory targets on the same path are not. Fix: during planning, a route
whose HTML target is the directory prefix of another planned HTML target
keeps Worker rendering (HTML skipped, tokenised RSC transport unaffected —
it is flat and cannot conflict). A directory at the HTML target is no
longer treated as a foreign file collision, pruning removes directories
that pruned descendant files leave empty so an ancestor can publish again
once its descendants disappear, and publishAsset degrades to "not
published" on directory-shape conflicts (EISDIR/ENOTDIR/ENOTEMPTY) instead
of aborting the prerender.

Test covers both directions: ancestor+descendant in one run publishes the
descendant and skips ancestor HTML; a following run without the descendant
prunes the directory and publishes the ancestor file.
… target

A pre-existing user asset file (e.g. dist/client/blog) blocks the parent
directory a descendant route target needs (dist/client/blog/post). The
foreign-collision check only inspects the exact target path, so the route
stayed planned and publishAsset's mkdirSync failed with ENOTDIR — and the
catch block's fs.rmSync temp-file cleanup threw ENOTDIR itself before the
error-code fallback could run, aborting the whole prerender.

The cleanup assumed the temp path's parent directory exists; with a file
in the parent chain it does not. Fix: temp-file cleanup is best-effort
(a temp file cannot exist if its parent directory does not), and EEXIST —
the Windows mkdir error for this shape — joins the safe "not published"
codes, so the route degrades to Worker rendering.

Regression test: a foreign parent file leaves the prerender publishing the
route's transport RSC only, with the user asset untouched and no HTML
header rule emitted.
@NathanDrake2406 NathanDrake2406 changed the title perf(cloudflare): split static App Router asset transport perf(cloudflare): serve static App Router HTML and RSC from Cloudflare Assets, bypassing the Worker Jul 4, 2026
The client transport define was enabled purely from "Cloudflare plugin
present + Wrangler assets config allows fallback" without checking
nextConfig.output. Export builds write plain about.rsc files next to the
HTML and the Cloudflare publisher explicitly skips transport publication
("static export already writes to client assets"), so the browser
requested /_next/static/__vinext/prerendered-rsc/<token>.rsc assets that
were never emitted — a guaranteed miss that at best falls through to the
Worker and at worst breaks navigation on a genuinely static deployment.

The define assumed every Cloudflare build routes through the prerender
publisher; export builds do not. Fix: the transport define additionally
requires nextConfig.output !== "export", keeping export clients on public
route RSC URLs that match what export actually emits.

Test proves the gate flips: same root defines "true" without the export
config and "false" with it.
@james-elicx

Copy link
Copy Markdown
Member

If a file is SSG'd, it should end up in the client directory regardless of if it's cloudflare or not. If it's not SSG'd, it should go down the normal ISR route through stubbing the cache.

@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

If a file is SSG'd, it should end up in the client directory regardless of if it's cloudflare or not. If it's not SSG'd, it should go down the normal ISR route through stubbing the cache.

agree, will follow up for node

Static RSC assets published to Cloudflare Assets wrote x-deployment-id
(NEXT_DEPLOYMENT_ID_HEADER from utils/deployment-id) into the _headers
block, while the dynamic Worker path emits x-nextjs-deployment-id
(NEXTJS_DEPLOYMENT_ID_HEADER from server/headers) via
applyRscDeploymentIdHeader(). The Worker-served and Asset-served RSC
paths now expose the same deployment-skew header.
…sets

runPrerender() called publishCloudflarePrerenderedAppAssets() before
assertNoFatalPrerenderRoutes(), so a fatal generateStaticParams() throw
in one route could mutate dist/client while another route rendered
successfully — before the build aborted. Move the fatal assertion before
the Cloudflare publication call so failed builds never publish new
static asset outputs.

Also add ENAMETOOLONG to publishAsset()'s safe-degrade errno list. The
basename guard (isPublishableAssetTarget) only checks the final path
segment; a deeply nested route with short segments can still exceed the
OS full-path limit and crash the publish phase instead of degrading to
Worker rendering, which is the PR's fail-soft contract.
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.

2 participants