perf(cloudflare): serve static App Router HTML and RSC from Cloudflare Assets, bypassing the Worker#2506
Conversation
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.
commit: |
Performance benchmarksCompared 1 improved · 0 regressed · 5 within ±1.5%
View detailed results and traces 🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head |
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.
…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.
…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).
… 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.
…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.
….com/NathanDrake2406/vinext into nathan/static-artifact-worker-bypass
|
@codex review |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
…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.
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.
|
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.
Overview
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.tsFollow-up review fixes
Since the initial description, these correctness fixes were added:
_headersrule limit_headersrule each; a large static app could exceed Cloudflare's 100-rule limit and produce an invalid asset bundle.runPrerender()ran outsidewithCloudflareEnv, so the publisher could read an undefinedCLOUDFLARE_ENVand disagree with the build's selected environment.runPrerender()inwithCloudflareEnv(buildEnv).deploy --skip-build) saw its own output as a collision, planned nothing, and dropped the generated_headersblock — leaving assets served without headers.publishAssetoverwrites owned/fresh targets with the current source via temp-file + rename (atomic).=padding,+//alphabet), and invalid UTF-8 collapsed distinct byte sequences into replacement-character routes.TextDecoder("utf-8", { fatal: true }).?/#smuggled query/fragment syntax when concatenated into the mapped URL.resolveRscTransportRequestassignsmappedUrl.pathnameinstead of string-concatenating, and decoded tokens must pass pathname validation.url.pathnameassignment:/foo\bar→/foo/bar,/\evil.com→//evil.com, newlines stripped,/foo/../bar→/bar— breaking the bijective route-token invariant.isUrlSerializedPathnamerequires 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.getIntentruns before the method check, soPOST /aboutagainst a published HTML asset returns 405 from the asset worker and never invokes the Worker (confirmed in theworkers-sharedasset-worker source).hasServerActionsinvinext-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.vinext()closure; its ssr env wrotevinext-server.jsonlast and droppedhasServerActions(secret-keyed preservation never matched across builders), so the publisher skipped visible HTML for every hybrid CLI build.writeBundle./blogneedsdist/client/blogas a file while/blog/postneeds it as a directory; the plan phase checked targets independently, so publishing both aborted the prerender (ENOTDIR or EISDIR depending on route order).publishAssetdegrades to "not published" on directory-shape conflicts instead of aborting.dist/client/blog) blocks the parent directory a descendant target needs (dist/client/blog/post); the exact-target foreign check missed it,mkdirSyncthrew ENOTDIR, and the catch block's temp-filermSyncthrew ENOTDIR itself before the fallback could run — aborting prerender.publishAssettemp-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 mismatchabout.rscnext to the HTML and the publisher skips transport publication, so the browser requested reserved transport assets that were never emitted.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) andresolveWranglerJsonPath, previously copied acrossinit-cloudflareand the assets-config reader.Why
Cloudflare Assets matches by path before the Worker can inspect RSC headers. A public HTML asset at
/aboutis 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./about./404, and/500.Visible HTML additionally requires the build to contain no server actions.queryInvariant.html === truefor HTML andqueryInvariant.rsc === truefor static RSC.not_found_handling: "none".Disabled entirely foroutput: "export"builds, which never emit transport assets.What changed
/aboutdist/client/aboutdirectly./about/about?_rsc=..., which collides with a visible HTML asset./_next/static/__vinext/prerendered-rsc/about.rsc?_rsc./__vinext/rsc/about.rsc?_rsc=...; Worker maps it back to/aboutbefore dispatch._rschashBenchmark
Local paired Workers benchmark (script kept out of the PR diff; two concurrent
wrangler devservers kept alive, samples alternate AB/BA per round, sanity-gated: head must serve/aboutwithx-vinext-cache: STATIC, base must not):tests/fixtures/cf-app-basic, copied to a temp dir withmiddleware.tsremoved so middleware cannot observe requests.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_headersblock as the manifest), so requests fall through to the Worker.fetch()latency including body consumption.benchmarks/results/static-artifact-worker-bypass/2026-07-04T08-43-32Z/summary.mdin the local ignored benchmark output.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
packages/vinext/src/server/app-rsc-transport.tspackages/vinext/src/server/app-rsc-cache-busting.ts_rscURLs to split transport URLs.packages/vinext/src/server/app-router-entry.ts_rscredirect handling before normal App Router dispatch.packages/vinext/src/build/cloudflare-prerender-assets.ts_headersoutput.packages/vinext/src/index.tstests/app-rsc-cache-busting.test.tsandtests/cloudflare-prerender-assets.test.tsValidation
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.tsvp 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.tsvp test run tests/app-rsc-cache-busting.test.ts tests/cloudflare-prerender-assets.test.tsvp test run tests/cloudflare-rsc-transport-config.test.ts tests/deploy-prerender-config.test.tsvp test run tests/prerender.test.ts -t "Cloudflare Workers hybrid build"hasServerActionsis set, repeat prerender prunes previously published HTML, and the hybrid Cloudflare build records the flag invinext-server.json._headersrule-limit skip, idempotent re-run preserves the generated block, owned-asset overwrite when prerender output changes, and deploy setsCLOUDFLARE_ENVduring 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#buildtests/fixtures/cf-app-basicbuild withnode packages/vinext/dist/cli.js build --prerender-allwrangler devprobes verified:/aboutreturns static HTML withx-vinext-cache: STATIC./_next/static/__vinext/prerendered-rsc/about.rsc?_rscreturnstext/x-componentwith static cache headers./__vinext/rsc/about.rsc?_rsc=staleredirects to/__vinext/rsc/about.rsc?_rsc=<hash>, then returnstext/x-componentthrough the Worker path.Risk / compatibility
Compatibility notes
/_next/static/__vinext/prerendered-rsc./404, or/500stay Worker-owned.Non-goals
Out of scope