From 3d055e0fc8db22c35ade0e03ab98211938f2ed3b Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 4 Jul 2026 00:00:56 +1000 Subject: [PATCH 1/7] perf(build): publish static app prerenders as Cloudflare assets 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. --- .../src/build/cloudflare-prerender-assets.ts | 316 ++++++++++++++++++ packages/vinext/src/build/run-prerender.ts | 27 +- tests/cloudflare-prerender-assets.test.ts | 184 ++++++++++ 3 files changed, 517 insertions(+), 10 deletions(-) create mode 100644 packages/vinext/src/build/cloudflare-prerender-assets.ts create mode 100644 tests/cloudflare-prerender-assets.test.ts diff --git a/packages/vinext/src/build/cloudflare-prerender-assets.ts b/packages/vinext/src/build/cloudflare-prerender-assets.ts new file mode 100644 index 000000000..cfc56096a --- /dev/null +++ b/packages/vinext/src/build/cloudflare-prerender-assets.ts @@ -0,0 +1,316 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { ResolvedNextConfig } from "../config/next-config.js"; +import { createValidFileMatcher } from "../routing/file-matcher.js"; +import { + VINEXT_RSC_COMPATIBILITY_ID_HEADER, + VINEXT_RSC_CONTENT_TYPE, + VINEXT_RSC_VARY_HEADER, +} from "../server/app-rsc-cache-busting.js"; +import { STATIC_CACHE_CONTROL } from "../server/cache-control.js"; +import { NEXTJS_CACHE_HEADER, VINEXT_CACHE_HEADER } from "../server/headers.js"; +import { NEXT_DEPLOYMENT_ID_HEADER } from "../utils/deployment-id.js"; +import { getOutputPath, getRscOutputPath } from "../utils/prerender-output-paths.js"; +import { isUnknownRecord } from "../utils/record.js"; +import type { PrerenderRouteResult } from "./prerender.js"; + +type WranglerAssetsConfig = { + directory: string; + notFoundHandling?: string; +}; + +export type PublishCloudflarePrerenderedAppAssetsResult = + | { + publishedFiles: number; + publishedRoutes: number; + skipped: false; + } + | { + publishedFiles: 0; + publishedRoutes: 0; + reason: string; + skipped: true; + }; + +const GENERATED_HEADERS_START = "# Static prerendered App Router assets (generated by vinext)"; +const GENERATED_HEADERS_END = "# End static prerendered App Router assets"; + +function readWranglerAssetsConfig(serverDir: string): WranglerAssetsConfig | null { + const wranglerPath = path.join(serverDir, "wrangler.json"); + if (!fs.existsSync(wranglerPath)) return null; + + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(wranglerPath, "utf-8")); + } catch { + return null; + } + + if (!isUnknownRecord(parsed) || !isUnknownRecord(parsed.assets)) return null; + const directory = parsed.assets.directory; + if (typeof directory !== "string" || directory.length === 0) return null; + + const notFoundHandling = parsed.assets.not_found_handling; + return { + directory, + notFoundHandling: typeof notFoundHandling === "string" ? notFoundHandling : undefined, + }; +} + +function hasMiddlewareOrProxy(root: string, config: ResolvedNextConfig): boolean { + const matcher = createValidFileMatcher(config.pageExtensions); + for (const conventionDir of [root, path.join(root, "src")]) { + for (const ext of matcher.dottedExtensions) { + if ( + fs.existsSync(path.join(conventionDir, `proxy${ext}`)) || + fs.existsSync(path.join(conventionDir, `middleware${ext}`)) + ) { + return true; + } + } + } + return false; +} + +function hasRequestTransformConfig(config: ResolvedNextConfig): boolean { + return ( + config.headers.length > 0 || + config.redirects.length > 0 || + config.rewrites.beforeFiles.length > 0 || + config.rewrites.afterFiles.length > 0 || + config.rewrites.fallback.length > 0 + ); +} + +function safeAssetPathForRoute(routePathname: string): string | null { + if (routePathname === "/") return "index.html"; + if (!routePathname.startsWith("/")) return null; + + const segments = routePathname.slice(1).split("/"); + if ( + segments.length === 0 || + segments.some((segment) => segment.length === 0 || segment === "." || segment === "..") + ) { + return null; + } + + return segments.join("/"); +} + +function copyIfAbsent(sourcePath: string, targetPath: string): boolean { + if (!fs.existsSync(sourcePath) || fs.existsSync(targetPath)) return false; + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.copyFileSync(sourcePath, targetPath); + return true; +} + +function headerBlockForPath(pathname: string, headers: Record): string[] { + const lines = [pathname]; + for (const [name, value] of Object.entries(headers)) { + lines.push(` ${name}: ${value}`); + } + return lines; +} + +function removeGeneratedHeadersBlock(content: string): string { + const start = content.indexOf(GENERATED_HEADERS_START); + if (start === -1) return content.replace(/\s*$/, ""); + + const end = content.indexOf(GENERATED_HEADERS_END, start); + if (end === -1) return content.slice(0, start).replace(/\s*$/, ""); + + return (content.slice(0, start) + content.slice(end + GENERATED_HEADERS_END.length)).replace( + /\s*$/, + "", + ); +} + +function writeGeneratedHeadersBlock( + clientDir: string, + entries: Array<{ headers: Record; pathname: string }>, +): void { + if (entries.length === 0) return; + + const headersPath = path.join(clientDir, "_headers"); + const existing = fs.existsSync(headersPath) ? fs.readFileSync(headersPath, "utf-8") : ""; + const preserved = removeGeneratedHeadersBlock(existing); + const generated = [ + GENERATED_HEADERS_START, + ...entries.flatMap((entry) => headerBlockForPath(entry.pathname, entry.headers)), + GENERATED_HEADERS_END, + "", + ].join("\n"); + + const nextContent = preserved.length > 0 ? `${preserved}\n\n${generated}` : generated; + fs.mkdirSync(clientDir, { recursive: true }); + fs.writeFileSync(headersPath, nextContent); +} + +function staticCacheHeaders(contentType: string): Record { + return { + "Content-Type": contentType, + "Cache-Control": STATIC_CACHE_CONTROL, + [VINEXT_CACHE_HEADER]: "STATIC", + [NEXTJS_CACHE_HEADER]: "HIT", + }; +} + +function buildHtmlHeaders(route: Extract) { + return { + ...staticCacheHeaders("text/html; charset=utf-8"), + ...(route.headers?.link ? { Link: route.headers.link } : {}), + }; +} + +function buildRscHeaders(options: { + deploymentId: string | undefined; + rscCompatibilityId: string | undefined; +}): Record { + return { + ...staticCacheHeaders(VINEXT_RSC_CONTENT_TYPE), + Vary: VINEXT_RSC_VARY_HEADER, + ...(options.rscCompatibilityId + ? { [VINEXT_RSC_COMPATIBILITY_ID_HEADER]: options.rscCompatibilityId } + : {}), + ...(options.deploymentId ? { [NEXT_DEPLOYMENT_ID_HEADER]: options.deploymentId } : {}), + }; +} + +function eligibleStaticAppRoutes( + routes: readonly PrerenderRouteResult[], +): Array> { + return routes.filter((route): route is Extract => { + const routePathname = route.status === "rendered" ? (route.path ?? route.route) : route.route; + return ( + route.status === "rendered" && + route.router === "app" && + route.revalidate === false && + route.fallback !== true && + routePathname !== "/404" && + routePathname !== "/500" + ); + }); +} + +export function publishCloudflarePrerenderedAppAssets(options: { + config: ResolvedNextConfig; + prerenderDir: string; + root: string; + routes: readonly PrerenderRouteResult[]; + rscCompatibilityId?: string; + serverDir: string; +}): PublishCloudflarePrerenderedAppAssetsResult { + if (options.config.output === "export") { + return { + skipped: true, + reason: "static export already writes to client assets", + publishedFiles: 0, + publishedRoutes: 0, + }; + } + if (options.config.trailingSlash) { + return { + skipped: true, + reason: "trailingSlash would require preserving slash redirects", + publishedFiles: 0, + publishedRoutes: 0, + }; + } + if (options.config.basePath) { + return { + skipped: true, + reason: "basePath asset route publication is not proven safe", + publishedFiles: 0, + publishedRoutes: 0, + }; + } + if (options.config.i18n) { + return { + skipped: true, + reason: "i18n routing may require Worker redirects or locale negotiation", + publishedFiles: 0, + publishedRoutes: 0, + }; + } + if (hasRequestTransformConfig(options.config)) { + return { + skipped: true, + reason: "config headers, redirects, or rewrites require Worker routing", + publishedFiles: 0, + publishedRoutes: 0, + }; + } + if (hasMiddlewareOrProxy(options.root, options.config)) { + return { + skipped: true, + reason: "middleware/proxy must run before page responses", + publishedFiles: 0, + publishedRoutes: 0, + }; + } + + const assetsConfig = readWranglerAssetsConfig(options.serverDir); + if (!assetsConfig) { + return { + skipped: true, + reason: "Cloudflare assets binding not found", + publishedFiles: 0, + publishedRoutes: 0, + }; + } + if (assetsConfig.notFoundHandling && assetsConfig.notFoundHandling !== "none") { + return { + skipped: true, + reason: "Cloudflare assets not_found_handling is not none", + publishedFiles: 0, + publishedRoutes: 0, + }; + } + + const clientDir = path.resolve(options.serverDir, assetsConfig.directory); + const routes = eligibleStaticAppRoutes(options.routes); + const headerEntries: Array<{ headers: Record; pathname: string }> = []; + let publishedFiles = 0; + let publishedRoutes = 0; + + for (const route of routes) { + const routePathname = route.path ?? route.route; + const assetPath = safeAssetPathForRoute(routePathname); + if (!assetPath) continue; + + const htmlTarget = path.join(clientDir, assetPath); + const rscTarget = routePathname === "/" ? null : path.join(clientDir, `${assetPath}.rsc`); + if (fs.existsSync(htmlTarget) || (rscTarget && fs.existsSync(rscTarget))) continue; + + let routePublished = false; + const htmlSource = path.join(options.prerenderDir, getOutputPath(routePathname, false)); + if (copyIfAbsent(htmlSource, htmlTarget)) { + publishedFiles++; + routePublished = true; + headerEntries.push({ + pathname: routePathname, + headers: buildHtmlHeaders(route), + }); + } + + if (rscTarget) { + const rscSource = path.join(options.prerenderDir, getRscOutputPath(routePathname)); + if (copyIfAbsent(rscSource, rscTarget)) { + publishedFiles++; + routePublished = true; + headerEntries.push({ + pathname: `${routePathname}.rsc`, + headers: buildRscHeaders({ + deploymentId: options.config.deploymentId, + rscCompatibilityId: options.rscCompatibilityId, + }), + }); + } + } + + if (routePublished) publishedRoutes++; + } + + writeGeneratedHeadersBlock(clientDir, headerEntries); + return { skipped: false, publishedFiles, publishedRoutes }; +} diff --git a/packages/vinext/src/build/run-prerender.ts b/packages/vinext/src/build/run-prerender.ts index e4d640d9e..204c392e4 100644 --- a/packages/vinext/src/build/run-prerender.ts +++ b/packages/vinext/src/build/run-prerender.ts @@ -7,9 +7,10 @@ * * Output files (HTML/RSC payloads) are written to * `dist/server/prerendered-routes/` for non-export builds, co-located with - * server artifacts and away from the static assets directory. On Cloudflare - * Workers, `not_found_handling: "none"` means every request hits the worker - * first, so files in `dist/client/` are never auto-served for page requests. + * server artifacts. After the prerender manifest is written, a conservative + * Cloudflare publisher may copy fully static App Router artifacts into + * `dist/client/` for ASSETS-first serving when no middleware or config + * transforms can observe the request. * For `output: 'export'` builds the caller controls `outDir` via * `static-export.ts`, which passes `dist/client/` directly. * @@ -35,6 +36,7 @@ import { scanMetadataFiles } from "../server/metadata-routes.js"; import { findDir } from "../utils/project.js"; import { injectPregeneratedConcretePaths } from "./inject-pregenerated-paths.js"; import { rememberCurrentServerEntryImportMtime, startProdServer } from "../server/prod-server.js"; +import { publishCloudflarePrerenderedAppAssets } from "./cloudflare-prerender-assets.js"; // ─── Progress UI ────────────────────────────────────────────────────────────── @@ -219,13 +221,10 @@ export async function runPrerender(options: RunPrerenderOptions): Promise = {}, +): Promise { + const config = await resolveNextConfig({}); + return { + ...config, + basePath: "", + deploymentId: "deploy-test", + headers: [], + i18n: null, + redirects: [], + rewrites: { beforeFiles: [], afterFiles: [], fallback: [] }, + trailingSlash: false, + ...overrides, + }; +} + +function writeWrangler(serverDir: string): void { + writeFile( + path.join(serverDir, "wrangler.json"), + JSON.stringify({ + main: "index.js", + assets: { + binding: "ASSETS", + directory: "../client", + not_found_handling: "none", + }, + }), + ); +} + +function renderedAppRoute( + route: string, + outputFiles: string[], + extra: Partial> = {}, +): Extract { + return { + route, + status: "rendered", + outputFiles, + revalidate: false, + router: "app", + ...extra, + }; +} + +afterEach(() => { + for (const root of tempRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +describe("publishCloudflarePrerenderedAppAssets", () => { + it("publishes fully static App Router prerender artifacts into Cloudflare assets", async () => { + const root = createTempRoot(); + const serverDir = path.join(root, "dist/server"); + const prerenderDir = path.join(serverDir, "prerendered-routes"); + const clientDir = path.join(root, "dist/client"); + writeWrangler(serverDir); + writeFile( + path.join(clientDir, "_headers"), + "/_next/static/*\n Cache-Control: public, max-age=31536000, immutable\n", + ); + writeFile(path.join(prerenderDir, "index.html"), "

Home

"); + writeFile(path.join(prerenderDir, "index.rsc"), "home-rsc"); + writeFile(path.join(prerenderDir, "about.html"), "

About

"); + writeFile(path.join(prerenderDir, "about.rsc"), "about-rsc"); + writeFile(path.join(prerenderDir, "isr.html"), "

ISR

"); + writeFile(path.join(prerenderDir, "404.html"), "

Missing

"); + writeFile(path.join(prerenderDir, "taken.html"), "

Route

"); + writeFile(path.join(prerenderDir, "taken.rsc"), "route-rsc"); + writeFile(path.join(clientDir, "taken"), "

Existing asset

"); + writeFile(path.join(prerenderDir, "pages-home.html"), "

Pages

"); + + const result = publishCloudflarePrerenderedAppAssets({ + config: await baseConfig(), + prerenderDir, + root, + routes: [ + renderedAppRoute("/", ["index.html", "index.rsc"]), + renderedAppRoute("/about", ["about.html", "about.rsc"], { + headers: { link: "; rel=preload" }, + }), + renderedAppRoute("/isr", ["isr.html"], { revalidate: 60 }), + renderedAppRoute("/404", ["404.html"]), + renderedAppRoute("/taken", ["taken.html", "taken.rsc"]), + { + route: "/pages-home", + status: "rendered", + outputFiles: ["pages-home.html"], + revalidate: false, + router: "pages", + }, + ], + rscCompatibilityId: "rsc-compat-test", + serverDir, + }); + + expect(result).toEqual({ skipped: false, publishedFiles: 3, publishedRoutes: 2 }); + expect(fs.readFileSync(path.join(clientDir, "index.html"), "utf-8")).toBe("

Home

"); + expect(fs.readFileSync(path.join(clientDir, "about"), "utf-8")).toBe("

About

"); + expect(fs.readFileSync(path.join(clientDir, "about.rsc"), "utf-8")).toBe("about-rsc"); + expect(fs.existsSync(path.join(clientDir, "index.rsc"))).toBe(false); + expect(fs.existsSync(path.join(clientDir, "isr"))).toBe(false); + expect(fs.existsSync(path.join(clientDir, "404"))).toBe(false); + expect(fs.readFileSync(path.join(clientDir, "taken"), "utf-8")).toBe("

Existing asset

"); + expect(fs.existsSync(path.join(clientDir, "taken.rsc"))).toBe(false); + expect(fs.existsSync(path.join(clientDir, "pages-home"))).toBe(false); + + const headers = fs.readFileSync(path.join(clientDir, "_headers"), "utf-8"); + expect(headers).toContain("/_next/static/*"); + expect(headers).toContain("/\n Content-Type: text/html; charset=utf-8"); + expect(headers).toContain("/about\n Content-Type: text/html; charset=utf-8"); + expect(headers).toContain(" X-Vinext-Cache: STATIC"); + expect(headers).toContain(" x-nextjs-cache: HIT"); + expect(headers).toContain(" Link: ; rel=preload"); + expect(headers).toContain("/about.rsc\n Content-Type: text/x-component"); + expect(headers).toContain(" X-Vinext-RSC-Compatibility-Id: rsc-compat-test"); + expect(headers).toContain(" x-deployment-id: deploy-test"); + }); + + it("does not publish when middleware or config request transforms are present", async () => { + const middlewareRoot = createTempRoot(); + const middlewareServerDir = path.join(middlewareRoot, "dist/server"); + const middlewarePrerenderDir = path.join(middlewareServerDir, "prerendered-routes"); + writeWrangler(middlewareServerDir); + writeFile(path.join(middlewareRoot, "middleware.ts"), "export function middleware() {}\n"); + writeFile(path.join(middlewarePrerenderDir, "about.html"), "

About

"); + + const middlewareResult = publishCloudflarePrerenderedAppAssets({ + config: await baseConfig(), + prerenderDir: middlewarePrerenderDir, + root: middlewareRoot, + routes: [renderedAppRoute("/about", ["about.html"])], + serverDir: middlewareServerDir, + }); + + expect(middlewareResult).toMatchObject({ skipped: true, publishedFiles: 0 }); + expect(fs.existsSync(path.join(middlewareRoot, "dist/client/about"))).toBe(false); + + const headersRoot = createTempRoot(); + const headersServerDir = path.join(headersRoot, "dist/server"); + const headersPrerenderDir = path.join(headersServerDir, "prerendered-routes"); + writeWrangler(headersServerDir); + writeFile(path.join(headersPrerenderDir, "about.html"), "

About

"); + + const headersResult = publishCloudflarePrerenderedAppAssets({ + config: await baseConfig({ + headers: [{ source: "/about", headers: [{ key: "x-test", value: "1" }] }], + }), + prerenderDir: headersPrerenderDir, + root: headersRoot, + routes: [renderedAppRoute("/about", ["about.html"])], + serverDir: headersServerDir, + }); + + expect(headersResult).toMatchObject({ skipped: true, publishedFiles: 0 }); + expect(fs.existsSync(path.join(headersRoot, "dist/client/about"))).toBe(false); + }); +}); From fadb524d2c076cb57287ac260c77693c054a5527 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 4 Jul 2026 00:42:16 +1000 Subject: [PATCH 2/7] fix(cloudflare): require query proof before publishing app assets 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. --- .../src/build/cloudflare-prerender-assets.ts | 6 +- packages/vinext/src/build/prerender.ts | 28 +++++++ packages/vinext/src/server/app-page-render.ts | 62 +++++++++++++- packages/vinext/src/server/headers.ts | 4 + .../vinext/src/server/prerender-manifest.ts | 4 + tests/cloudflare-prerender-assets.test.ts | 83 ++++++++++++++++++- 6 files changed, 183 insertions(+), 4 deletions(-) diff --git a/packages/vinext/src/build/cloudflare-prerender-assets.ts b/packages/vinext/src/build/cloudflare-prerender-assets.ts index cfc56096a..f2885b83d 100644 --- a/packages/vinext/src/build/cloudflare-prerender-assets.ts +++ b/packages/vinext/src/build/cloudflare-prerender-assets.ts @@ -185,6 +185,7 @@ function eligibleStaticAppRoutes( route.status === "rendered" && route.router === "app" && route.revalidate === false && + route.queryInvariant?.html === true && route.fallback !== true && routePathname !== "/404" && routePathname !== "/500" @@ -280,7 +281,8 @@ export function publishCloudflarePrerenderedAppAssets(options: { const htmlTarget = path.join(clientDir, assetPath); const rscTarget = routePathname === "/" ? null : path.join(clientDir, `${assetPath}.rsc`); - if (fs.existsSync(htmlTarget) || (rscTarget && fs.existsSync(rscTarget))) continue; + const shouldPublishRsc = rscTarget !== null && route.queryInvariant?.rsc === true; + if (fs.existsSync(htmlTarget) || (shouldPublishRsc && fs.existsSync(rscTarget))) continue; let routePublished = false; const htmlSource = path.join(options.prerenderDir, getOutputPath(routePathname, false)); @@ -293,7 +295,7 @@ export function publishCloudflarePrerenderedAppAssets(options: { }); } - if (rscTarget) { + if (shouldPublishRsc) { const rscSource = path.join(options.prerenderDir, getRscOutputPath(routePathname)); if (copyIfAbsent(rscSource, rscTarget)) { publishedFiles++; diff --git a/packages/vinext/src/build/prerender.ts b/packages/vinext/src/build/prerender.ts index de1839c74..9d493d4e0 100644 --- a/packages/vinext/src/build/prerender.ts +++ b/packages/vinext/src/build/prerender.ts @@ -40,6 +40,7 @@ import { normalizeStaticPathsEntry, type StaticPathsEntry } from "../routing/rou import { navigationRuntimeRscBootstrapExpression } from "../server/app-ssr-stream.js"; import { VINEXT_PRERENDER_CACHE_LIFE_HEADER, + VINEXT_PRERENDER_QUERY_INVARIANT_HEADER, VINEXT_PRERENDER_ROUTE_PARAMS_HEADER, VINEXT_PRERENDER_SECRET_HEADER, VINEXT_PRERENDER_SPECULATIVE_HEADER, @@ -135,6 +136,11 @@ export type PrerenderResult = { outputFiles?: string[]; }; +type PrerenderQueryInvariant = { + html: boolean; + rsc: boolean; +}; + export type PrerenderRouteResult = | { /** The route's file-system pattern, e.g. `/blog/:slug`. */ @@ -153,6 +159,8 @@ export type PrerenderRouteResult = router: "app" | "pages"; /** Response headers that must be replayed with the prerendered artifact. */ headers?: Record; + /** Query-invariance proof for publishing prerendered artifacts as static assets. */ + queryInvariant?: PrerenderQueryInvariant; /** Set to true when this is a PPR fallback shell. */ fallback?: boolean; } @@ -1444,6 +1452,7 @@ export async function prerenderApp({ const cacheControl = response.headers.get("cache-control") ?? ""; const linkHeader = response.headers.get("link"); const responseCacheLife = readPrerenderCacheLifeHeader(response.headers); + const queryInvariant = readPrerenderQueryInvariantHeader(response.headers); if (!response.ok || cacheControl.includes("no-store")) { await response.body?.cancel(); return { @@ -1451,6 +1460,7 @@ export async function prerenderApp({ linkHeader, html: null, ok: response.ok, + queryInvariant, requestCacheLife: null, status: response.status, }; @@ -1466,6 +1476,7 @@ export async function prerenderApp({ linkHeader, html, ok: true, + queryInvariant, requestCacheLife: responseCacheLife ?? processCacheLife, status: response.status, }; @@ -1576,6 +1587,7 @@ export async function prerenderApp({ : {}), router: "app", ...(htmlRender.linkHeader ? { headers: { link: htmlRender.linkHeader } } : {}), + ...(htmlRender.queryInvariant ? { queryInvariant: htmlRender.queryInvariant } : {}), ...(urlPath !== routePattern ? { path: urlPath } : {}), ...(isFallback ? { fallback: true } : {}), }; @@ -1722,6 +1734,21 @@ function readPrerenderCacheLifeHeader( } } +function readPrerenderQueryInvariantHeader(headers: Headers): PrerenderQueryInvariant | null { + const value = headers.get(VINEXT_PRERENDER_QUERY_INVARIANT_HEADER); + if (!value) return null; + + try { + const parsed = JSON.parse(value) as { html?: unknown; rsc?: unknown }; + return { + html: parsed.html === true, + rsc: parsed.rsc === true, + }; + } catch { + return null; + } +} + function resolveRenderedExpireSeconds(options: { fallbackExpireSeconds: number; sMaxage?: number; @@ -1775,6 +1802,7 @@ export function writePrerenderIndex( ...(typeof r.revalidate === "number" ? { expire: r.expire } : {}), router: r.router, ...(r.headers ? { headers: r.headers } : {}), + ...(r.queryInvariant ? { queryInvariant: r.queryInvariant } : {}), ...(r.path ? { path: r.path } : {}), ...(r.fallback ? { fallback: true } : {}), }; diff --git a/packages/vinext/src/server/app-page-render.ts b/packages/vinext/src/server/app-page-render.ts index c3cb463bc..e347d2262 100644 --- a/packages/vinext/src/server/app-page-render.ts +++ b/packages/vinext/src/server/app-page-render.ts @@ -49,6 +49,7 @@ import { buildRenderRequestApiObservations, createStaticLayoutArtifactReuseDecision, DEFAULT_CACHE_VARIANT_BUDGET, + hasCompleteNegativeRequestApiProof, type StaticLayoutCacheProofOutputScope, } from "./cache-proof.js"; import type { @@ -82,6 +83,7 @@ import type { } from "./app-layout-param-observation.js"; import { getStaticLayoutObservationSkipRejection } from "./app-layout-param-observation.js"; import { peekDynamicUsage } from "vinext/shims/headers"; +import { VINEXT_PRERENDER_QUERY_INVARIANT_HEADER } from "./headers.js"; type AppPageBoundaryOnError = ( error: unknown, @@ -102,6 +104,11 @@ type AppPageRequestCacheLife = { expire?: number; }; +type AppPagePrerenderQueryInvariant = { + html: boolean; + rsc: boolean; +}; + type RenderAppPageLifecycleOptions = { basePath?: string; /** @@ -621,6 +628,41 @@ function wrapRscResponseForDevErrorReporting( }); } +function buildPrerenderQueryInvariantMetadata(options: { + cacheTags: readonly string[]; + cleanPathname: string; + htmlOutputScope: ReturnType; + navigationParams: Record; + rscOutputScope: ReturnType; + state: AppPageRenderObservationState; +}): AppPagePrerenderQueryInvariant { + const htmlObservation = createAppPageRenderObservation({ + boundaryOutcome: { kind: "success" }, + cacheability: "public", + cacheTags: options.cacheTags, + cleanPathname: options.cleanPathname, + completeness: "complete", + output: options.htmlOutputScope, + params: options.navigationParams, + state: options.state, + }); + const rscObservation = createAppPageRenderObservation({ + boundaryOutcome: { kind: "success" }, + cacheability: "public", + cacheTags: options.cacheTags, + cleanPathname: options.cleanPathname, + completeness: "complete", + output: options.rscOutputScope, + params: options.navigationParams, + state: options.state, + }); + + return { + html: hasCompleteNegativeRequestApiProof(htmlObservation, ["searchParams"]), + rsc: hasCompleteNegativeRequestApiProof(rscObservation, ["searchParams"]), + }; +} + export async function renderAppPageLifecycle( options: RenderAppPageLifecycleOptions, ): Promise { @@ -1141,7 +1183,25 @@ export async function renderAppPageLifecycle( }); if (options.isPrerender === true) { - return isrResponse; + const html = await isrResponse.text(); + const pageTags = options.getPageTags(); + const observationState = + options.consumeRenderObservationState?.() ?? createEmptyAppPageRenderObservationState(); + const queryInvariant = buildPrerenderQueryInvariantMetadata({ + cacheTags: pageTags, + cleanPathname: options.cleanPathname, + htmlOutputScope, + navigationParams: options.navigationParams, + rscOutputScope, + state: observationState, + }); + const headers = new Headers(isrResponse.headers); + headers.set(VINEXT_PRERENDER_QUERY_INVARIANT_HEADER, JSON.stringify(queryInvariant)); + return new Response(html, { + status: isrResponse.status, + statusText: isrResponse.statusText, + headers, + }); } return finalizeAppPageHtmlCacheResponse(isrResponse, { diff --git a/packages/vinext/src/server/headers.ts b/packages/vinext/src/server/headers.ts index 4fa4d9157..9ed65e9d7 100644 --- a/packages/vinext/src/server/headers.ts +++ b/packages/vinext/src/server/headers.ts @@ -67,6 +67,9 @@ export const VINEXT_RENDERED_PATH_AND_SEARCH_HEADER = "X-Vinext-Rendered-Path-An /** Prerender-only JSON side channel carrying request cacheLife metadata. */ export const VINEXT_PRERENDER_CACHE_LIFE_HEADER = "x-vinext-prerender-cache-life"; +/** Prerender-only JSON side channel carrying query-invariance proof metadata. */ +export const VINEXT_PRERENDER_QUERY_INVARIANT_HEADER = "x-vinext-prerender-query-invariant"; + /** Route interception context for parallel/intercepting routes. */ export const VINEXT_INTERCEPTION_CONTEXT_HEADER = "X-Vinext-Interception-Context"; @@ -216,4 +219,5 @@ export const VINEXT_INTERNAL_HEADERS = [ VINEXT_PRERENDER_ROUTE_PARAMS_HEADER, VINEXT_PRERENDER_SPECULATIVE_HEADER, VINEXT_PRERENDER_CACHE_LIFE_HEADER, + VINEXT_PRERENDER_QUERY_INVARIANT_HEADER, ]; diff --git a/packages/vinext/src/server/prerender-manifest.ts b/packages/vinext/src/server/prerender-manifest.ts index fe228b7c0..a80d13fcb 100644 --- a/packages/vinext/src/server/prerender-manifest.ts +++ b/packages/vinext/src/server/prerender-manifest.ts @@ -9,6 +9,10 @@ type PrerenderManifestRoute = { router?: string; fallback?: boolean; headers?: Record; + queryInvariant?: { + html?: boolean; + rsc?: boolean; + }; }; export type PrerenderManifest = { diff --git a/tests/cloudflare-prerender-assets.test.ts b/tests/cloudflare-prerender-assets.test.ts index ff8b4d064..14c727094 100644 --- a/tests/cloudflare-prerender-assets.test.ts +++ b/tests/cloudflare-prerender-assets.test.ts @@ -7,7 +7,10 @@ import { type ResolvedNextConfig, } from "../packages/vinext/src/config/next-config.js"; import { publishCloudflarePrerenderedAppAssets } from "../packages/vinext/src/build/cloudflare-prerender-assets.js"; -import type { PrerenderRouteResult } from "../packages/vinext/src/build/prerender.js"; +import { + writePrerenderIndex, + type PrerenderRouteResult, +} from "../packages/vinext/src/build/prerender.js"; const tempRoots: string[] = []; @@ -62,6 +65,7 @@ function renderedAppRoute( route, status: "rendered", outputFiles, + queryInvariant: { html: true, rsc: true }, revalidate: false, router: "app", ...extra, @@ -181,4 +185,81 @@ describe("publishCloudflarePrerenderedAppAssets", () => { expect(headersResult).toMatchObject({ skipped: true, publishedFiles: 0 }); expect(fs.existsSync(path.join(headersRoot, "dist/client/about"))).toBe(false); }); + + it("requires query-invariant prerender proof before publishing assets", async () => { + const root = createTempRoot(); + const serverDir = path.join(root, "dist/server"); + const prerenderDir = path.join(serverDir, "prerendered-routes"); + const clientDir = path.join(root, "dist/client"); + writeWrangler(serverDir); + writeFile(path.join(prerenderDir, "about.html"), "

About

"); + writeFile(path.join(prerenderDir, "about.rsc"), "about-rsc"); + + const result = publishCloudflarePrerenderedAppAssets({ + config: await baseConfig(), + prerenderDir, + root, + routes: [ + renderedAppRoute("/about", ["about.html", "about.rsc"], { + queryInvariant: undefined, + }), + ], + serverDir, + }); + + expect(result).toEqual({ skipped: false, publishedFiles: 0, publishedRoutes: 0 }); + expect(fs.existsSync(path.join(clientDir, "about"))).toBe(false); + expect(fs.existsSync(path.join(clientDir, "about.rsc"))).toBe(false); + expect(fs.existsSync(path.join(clientDir, "_headers"))).toBe(false); + }); + + it("publishes RSC assets only when the RSC query-invariance proof is present", async () => { + const root = createTempRoot(); + const serverDir = path.join(root, "dist/server"); + const prerenderDir = path.join(serverDir, "prerendered-routes"); + const clientDir = path.join(root, "dist/client"); + writeWrangler(serverDir); + writeFile(path.join(prerenderDir, "about.html"), "

About

"); + writeFile(path.join(prerenderDir, "about.rsc"), "about-rsc"); + + const result = publishCloudflarePrerenderedAppAssets({ + config: await baseConfig(), + prerenderDir, + root, + routes: [ + renderedAppRoute("/about", ["about.html", "about.rsc"], { + queryInvariant: { html: true, rsc: false }, + }), + ], + serverDir, + }); + + expect(result).toEqual({ skipped: false, publishedFiles: 1, publishedRoutes: 1 }); + expect(fs.readFileSync(path.join(clientDir, "about"), "utf-8")).toBe("

About

"); + expect(fs.existsSync(path.join(clientDir, "about.rsc"))).toBe(false); + + const headers = fs.readFileSync(path.join(clientDir, "_headers"), "utf-8"); + expect(headers).toContain("/about\n Content-Type: text/html; charset=utf-8"); + expect(headers).not.toContain("/about.rsc"); + }); + + it("preserves query-invariance proof in the prerender manifest", () => { + const root = createTempRoot(); + + writePrerenderIndex( + [ + renderedAppRoute("/about", ["about.html", "about.rsc"], { + queryInvariant: { html: true, rsc: false }, + }), + ], + root, + ); + + const manifest = JSON.parse(fs.readFileSync(path.join(root, "vinext-prerender.json"), "utf-8")); + expect(manifest.routes[0]).toMatchObject({ + route: "/about", + status: "rendered", + queryInvariant: { html: true, rsc: false }, + }); + }); }); From f9bcb3032640d68f7a4c3d95d2bca60dbe5e1f2d Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 4 Jul 2026 00:58:35 +1000 Subject: [PATCH 3/7] test(router): cover query-invariant internal header filtering 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. --- tests/request-pipeline.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/request-pipeline.test.ts b/tests/request-pipeline.test.ts index dde9e547f..55a28fcb5 100644 --- a/tests/request-pipeline.test.ts +++ b/tests/request-pipeline.test.ts @@ -20,6 +20,7 @@ import { } from "../packages/vinext/src/server/request-pipeline.js"; import { VINEXT_PRERENDER_CACHE_LIFE_HEADER, + VINEXT_PRERENDER_QUERY_INVARIANT_HEADER, VINEXT_PRERENDER_ROUTE_PARAMS_HEADER, VINEXT_PRERENDER_SPECULATIVE_HEADER, } from "../packages/vinext/src/server/headers.js"; @@ -799,6 +800,7 @@ describe("filterInternalHeaders", () => { it("strips vinext-only internal headers without extending Next.js INTERNAL_HEADERS", () => { const headers = new Headers({ [VINEXT_PRERENDER_CACHE_LIFE_HEADER]: "forged", + [VINEXT_PRERENDER_QUERY_INVARIANT_HEADER]: "forged", [VINEXT_PRERENDER_ROUTE_PARAMS_HEADER]: "forged", [VINEXT_PRERENDER_SPECULATIVE_HEADER]: "forged", "user-agent": "test", @@ -809,10 +811,12 @@ describe("filterInternalHeaders", () => { expect(INTERNAL_HEADERS).not.toContain(VINEXT_PRERENDER_ROUTE_PARAMS_HEADER); expect(INTERNAL_HEADERS).not.toContain(VINEXT_PRERENDER_SPECULATIVE_HEADER); expect(INTERNAL_HEADERS).not.toContain(VINEXT_PRERENDER_CACHE_LIFE_HEADER); + expect(INTERNAL_HEADERS).not.toContain(VINEXT_PRERENDER_QUERY_INVARIANT_HEADER); expect(VINEXT_INTERNAL_HEADERS).toEqual([ VINEXT_PRERENDER_ROUTE_PARAMS_HEADER, VINEXT_PRERENDER_SPECULATIVE_HEADER, VINEXT_PRERENDER_CACHE_LIFE_HEADER, + VINEXT_PRERENDER_QUERY_INVARIANT_HEADER, ]); for (const name of VINEXT_INTERNAL_HEADERS) { expect(name).toBe(name.toLowerCase()); @@ -820,6 +824,7 @@ describe("filterInternalHeaders", () => { expect(result.has(VINEXT_PRERENDER_ROUTE_PARAMS_HEADER)).toBe(false); expect(result.has(VINEXT_PRERENDER_SPECULATIVE_HEADER)).toBe(false); expect(result.has(VINEXT_PRERENDER_CACHE_LIFE_HEADER)).toBe(false); + expect(result.has(VINEXT_PRERENDER_QUERY_INVARIANT_HEADER)).toBe(false); expect(result.get("user-agent")).toBe("test"); }); From ae1a56dd7dc53cf9b5ba84749422c954b1984171 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 4 Jul 2026 01:02:15 +1000 Subject: [PATCH 4/7] fix(cloudflare): skip app asset routes on RSC target collisions 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. --- .../src/build/cloudflare-prerender-assets.ts | 2 +- tests/cloudflare-prerender-assets.test.ts | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/build/cloudflare-prerender-assets.ts b/packages/vinext/src/build/cloudflare-prerender-assets.ts index f2885b83d..589afd0cd 100644 --- a/packages/vinext/src/build/cloudflare-prerender-assets.ts +++ b/packages/vinext/src/build/cloudflare-prerender-assets.ts @@ -282,7 +282,7 @@ export function publishCloudflarePrerenderedAppAssets(options: { const htmlTarget = path.join(clientDir, assetPath); const rscTarget = routePathname === "/" ? null : path.join(clientDir, `${assetPath}.rsc`); const shouldPublishRsc = rscTarget !== null && route.queryInvariant?.rsc === true; - if (fs.existsSync(htmlTarget) || (shouldPublishRsc && fs.existsSync(rscTarget))) continue; + if (fs.existsSync(htmlTarget) || (rscTarget && fs.existsSync(rscTarget))) continue; let routePublished = false; const htmlSource = path.join(options.prerenderDir, getOutputPath(routePathname, false)); diff --git a/tests/cloudflare-prerender-assets.test.ts b/tests/cloudflare-prerender-assets.test.ts index 14c727094..8d5c907f8 100644 --- a/tests/cloudflare-prerender-assets.test.ts +++ b/tests/cloudflare-prerender-assets.test.ts @@ -243,6 +243,36 @@ describe("publishCloudflarePrerenderedAppAssets", () => { expect(headers).not.toContain("/about.rsc"); }); + it("skips HTML publication when an RSC target already exists without RSC proof", async () => { + const root = createTempRoot(); + const serverDir = path.join(root, "dist/server"); + const prerenderDir = path.join(serverDir, "prerendered-routes"); + const clientDir = path.join(root, "dist/client"); + writeWrangler(serverDir); + writeFile(path.join(prerenderDir, "about.html"), "

About

"); + writeFile(path.join(prerenderDir, "about.rsc"), "about-rsc"); + writeFile(path.join(clientDir, "about.rsc"), "existing-user-rsc-asset"); + + const result = publishCloudflarePrerenderedAppAssets({ + config: await baseConfig(), + prerenderDir, + root, + routes: [ + renderedAppRoute("/about", ["about.html", "about.rsc"], { + queryInvariant: { html: true, rsc: false }, + }), + ], + serverDir, + }); + + expect(result).toEqual({ skipped: false, publishedFiles: 0, publishedRoutes: 0 }); + expect(fs.existsSync(path.join(clientDir, "about"))).toBe(false); + expect(fs.readFileSync(path.join(clientDir, "about.rsc"), "utf-8")).toBe( + "existing-user-rsc-asset", + ); + expect(fs.existsSync(path.join(clientDir, "_headers"))).toBe(false); + }); + it("preserves query-invariance proof in the prerender manifest", () => { const root = createTempRoot(); From ee7cfc46ebd08bea5b89a25ecb0f510e123dcf4b Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 4 Jul 2026 01:56:22 +1000 Subject: [PATCH 5/7] perf(cloudflare): split static App Router RSC transport 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. --- .../src/build/cloudflare-prerender-assets.ts | 78 ++++++++---- packages/vinext/src/build/run-prerender.ts | 8 +- packages/vinext/src/index.ts | 118 +++++++++++++++--- .../vinext/src/server/app-router-entry.ts | 16 ++- .../src/server/app-rsc-cache-busting.ts | 7 ++ .../vinext/src/server/app-rsc-transport.ts | 80 ++++++++++++ tests/app-rsc-cache-busting.test.ts | 51 ++++++++ tests/cloudflare-prerender-assets.test.ts | 61 ++++++--- 8 files changed, 359 insertions(+), 60 deletions(-) create mode 100644 packages/vinext/src/server/app-rsc-transport.ts diff --git a/packages/vinext/src/build/cloudflare-prerender-assets.ts b/packages/vinext/src/build/cloudflare-prerender-assets.ts index 589afd0cd..62507539a 100644 --- a/packages/vinext/src/build/cloudflare-prerender-assets.ts +++ b/packages/vinext/src/build/cloudflare-prerender-assets.ts @@ -7,6 +7,10 @@ import { VINEXT_RSC_CONTENT_TYPE, VINEXT_RSC_VARY_HEADER, } from "../server/app-rsc-cache-busting.js"; +import { + createRscTransportAssetPathname, + VINEXT_STATIC_RSC_TRANSPORT_PREFIX, +} from "../server/app-rsc-transport.js"; import { STATIC_CACHE_CONTROL } from "../server/cache-control.js"; import { NEXTJS_CACHE_HEADER, VINEXT_CACHE_HEADER } from "../server/headers.js"; import { NEXT_DEPLOYMENT_ID_HEADER } from "../utils/deployment-id.js"; @@ -82,8 +86,8 @@ function hasRequestTransformConfig(config: ResolvedNextConfig): boolean { ); } -function safeAssetPathForRoute(routePathname: string): string | null { - if (routePathname === "/") return "index.html"; +function safeVisibleAssetPathForRoute(routePathname: string): string | null { + if (routePathname === "/") return "index"; if (!routePathname.startsWith("/")) return null; const segments = routePathname.slice(1).split("/"); @@ -97,11 +101,15 @@ function safeAssetPathForRoute(routePathname: string): string | null { return segments.join("/"); } -function copyIfAbsent(sourcePath: string, targetPath: string): boolean { - if (!fs.existsSync(sourcePath) || fs.existsSync(targetPath)) return false; +function copyIfAbsent( + sourcePath: string, + targetPath: string, +): "copied" | "missing" | "target-exists" { + if (!fs.existsSync(sourcePath)) return "missing"; + if (fs.existsSync(targetPath)) return "target-exists"; fs.mkdirSync(path.dirname(targetPath), { recursive: true }); fs.copyFileSync(sourcePath, targetPath); - return true; + return "copied"; } function headerBlockForPath(pathname: string, headers: Record): string[] { @@ -129,11 +137,18 @@ function writeGeneratedHeadersBlock( clientDir: string, entries: Array<{ headers: Record; pathname: string }>, ): void { - if (entries.length === 0) return; - const headersPath = path.join(clientDir, "_headers"); const existing = fs.existsSync(headersPath) ? fs.readFileSync(headersPath, "utf-8") : ""; const preserved = removeGeneratedHeadersBlock(existing); + if (entries.length === 0) { + if (preserved.length > 0) { + fs.writeFileSync(headersPath, `${preserved}\n`); + } else { + fs.rmSync(headersPath, { force: true }); + } + return; + } + const generated = [ GENERATED_HEADERS_START, ...entries.flatMap((entry) => headerBlockForPath(entry.pathname, entry.headers)), @@ -276,41 +291,58 @@ export function publishCloudflarePrerenderedAppAssets(options: { for (const route of routes) { const routePathname = route.path ?? route.route; - const assetPath = safeAssetPathForRoute(routePathname); - if (!assetPath) continue; - - const htmlTarget = path.join(clientDir, assetPath); - const rscTarget = routePathname === "/" ? null : path.join(clientDir, `${assetPath}.rsc`); - const shouldPublishRsc = rscTarget !== null && route.queryInvariant?.rsc === true; - if (fs.existsSync(htmlTarget) || (rscTarget && fs.existsSync(rscTarget))) continue; + const visibleAssetPath = safeVisibleAssetPathForRoute(routePathname); + if (!visibleAssetPath) continue; + + let rscAssetPathname: string | null = null; + try { + rscAssetPathname = `${VINEXT_STATIC_RSC_TRANSPORT_PREFIX}${createRscTransportAssetPathname( + routePathname, + )}`; + } catch { + rscAssetPathname = null; + } + if (!rscAssetPathname) continue; + + const htmlAssetPath = routePathname === "/" ? "index.html" : visibleAssetPath; + const htmlTarget = path.join(clientDir, htmlAssetPath); + const rscTarget = path.join(clientDir, `.${rscAssetPathname}`); + const shouldPublishRsc = route.queryInvariant?.rsc === true; + if (fs.existsSync(htmlTarget) || fs.existsSync(rscTarget)) { + continue; + } let routePublished = false; + const htmlHeaders = buildHtmlHeaders(route); const htmlSource = path.join(options.prerenderDir, getOutputPath(routePathname, false)); - if (copyIfAbsent(htmlSource, htmlTarget)) { + if (copyIfAbsent(htmlSource, htmlTarget) === "copied") { publishedFiles++; routePublished = true; headerEntries.push({ pathname: routePathname, - headers: buildHtmlHeaders(route), + headers: htmlHeaders, }); } if (shouldPublishRsc) { const rscSource = path.join(options.prerenderDir, getRscOutputPath(routePathname)); - if (copyIfAbsent(rscSource, rscTarget)) { + const rscHeaders = buildRscHeaders({ + deploymentId: options.config.deploymentId, + rscCompatibilityId: options.rscCompatibilityId, + }); + if (copyIfAbsent(rscSource, rscTarget) === "copied") { publishedFiles++; routePublished = true; headerEntries.push({ - pathname: `${routePathname}.rsc`, - headers: buildRscHeaders({ - deploymentId: options.config.deploymentId, - rscCompatibilityId: options.rscCompatibilityId, - }), + pathname: rscAssetPathname, + headers: rscHeaders, }); } } - if (routePublished) publishedRoutes++; + if (routePublished) { + publishedRoutes++; + } } writeGeneratedHeadersBlock(clientDir, headerEntries); diff --git a/packages/vinext/src/build/run-prerender.ts b/packages/vinext/src/build/run-prerender.ts index 204c392e4..5725c2e57 100644 --- a/packages/vinext/src/build/run-prerender.ts +++ b/packages/vinext/src/build/run-prerender.ts @@ -8,9 +8,9 @@ * Output files (HTML/RSC payloads) are written to * `dist/server/prerendered-routes/` for non-export builds, co-located with * server artifacts. After the prerender manifest is written, a conservative - * Cloudflare publisher may copy fully static App Router artifacts into - * `dist/client/` for ASSETS-first serving when no middleware or config - * transforms can observe the request. + * Cloudflare publisher may copy fully static App Router HTML into visible + * assets and RSC payloads into a reserved transport namespace so browser RSC + * requests cannot be shadowed by document assets. * For `output: 'export'` builds the caller controls `outDir` via * `static-export.ts`, which passes `dist/client/` directly. * @@ -224,7 +224,7 @@ export async function runPrerender(options: RunPrerenderOptions): Promise + Array.isArray(plugin) ? flattenPluginCandidates(plugin) : [plugin], + ); +} + +function hasCloudflareVitePlugin(plugins: unknown): boolean { + return flattenPluginCandidates(plugins).some(isCloudflareVitePlugin); +} + +function stripJsonComments(source: string): string { + let output = ""; + let inString = false; + let escaped = false; + for (let index = 0; index < source.length; index++) { + const char = source[index]; + const next = source[index + 1]; + + if (inString) { + output += char; + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === '"') { + inString = false; + } + continue; + } + + if (char === '"') { + inString = true; + output += char; + continue; + } + + if (char === "/" && next === "/") { + while (index < source.length && source[index] !== "\n") index++; + output += "\n"; + continue; + } + + if (char === "/" && next === "*") { + index += 2; + while (index < source.length && !(source[index] === "*" && source[index + 1] === "/")) { + output += source[index] === "\n" ? "\n" : " "; + index++; + } + index++; + continue; + } + + output += char; + } + return output; +} + +function parseJsonOrJsonc(source: string): unknown { + try { + return JSON.parse(source); + } catch { + return JSON.parse(stripJsonComments(source).replace(/,\s*([}\]])/g, "$1")); + } +} + +function shouldEnableCloudflareRscTransport(root: string, plugins: unknown): boolean { + if (!hasCloudflareVitePlugin(plugins)) return false; + + const wranglerPath = ["wrangler.jsonc", "wrangler.json"] + .map((filename) => path.join(root, filename)) + .find((candidate) => fs.existsSync(candidate)); + if (!wranglerPath) return true; + + let parsed: unknown; + try { + parsed = parseJsonOrJsonc(fs.readFileSync(wranglerPath, "utf-8")); + } catch { + return false; + } + + if (!isRecord(parsed) || parsed.assets === undefined) return true; + if (!isRecord(parsed.assets)) return false; + const notFoundHandling = parsed.assets.not_found_handling; + return notFoundHandling === undefined || notFoundHandling === "none"; +} + export default function vinext(options: VinextOptions = {}): PluginOption[] { assertSupportedViteVersion(); const prerenderConfig = normalizeVinextPrerenderConfig(options.prerender); @@ -1942,6 +2039,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { defines["process.env.__VINEXT_PREFETCH_INLINING"] = JSON.stringify( nextConfig.prefetchInlining ? "true" : "false", ); + defines["process.env.__VINEXT_CLOUDFLARE_RSC_TRANSPORT"] = JSON.stringify( + shouldEnableCloudflareRscTransport(root, config.plugins) ? "true" : "false", + ); // Emit a raw boolean (not the "true"/"false" string form used by the // sibling defines above): the consumer guards with // `if (process.env.__NEXT_GESTURE_TRANSITION)`, so the literal `false` @@ -2237,22 +2337,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // Detect if Cloudflare's vite plugin is present — if so, skip // SSR externals (Workers bundle everything, can't have Node.js externals). - const pluginsFlat: unknown[] = []; - function flattenPlugins(arr: unknown[]) { - for (const p of arr) { - if (Array.isArray(p)) flattenPlugins(p); - else if (p) pluginsFlat.push(p); - } - } - flattenPlugins((config.plugins as unknown[]) ?? []); - hasCloudflarePlugin = pluginsFlat.some( - (p: unknown) => - p && - typeof p === "object" && - "name" in p && - typeof p.name === "string" && - (p.name === "vite-plugin-cloudflare" || p.name.startsWith("vite-plugin-cloudflare:")), - ); + const pluginsFlat = flattenPluginCandidates(config.plugins); + hasCloudflarePlugin = pluginsFlat.some(isCloudflareVitePlugin); hasNitroPlugin = pluginsFlat.some( (p: unknown) => p && diff --git a/packages/vinext/src/server/app-router-entry.ts b/packages/vinext/src/server/app-router-entry.ts index 902d4dfa6..596a4875b 100644 --- a/packages/vinext/src/server/app-router-entry.ts +++ b/packages/vinext/src/server/app-router-entry.ts @@ -39,6 +39,11 @@ import { handleConfiguredImageOptimization, isImageOptimizationPath, } from "./image-optimization.js"; +import { + resolveRscTransportRequest, + resolveRscTransportRoutePathname, +} from "./app-rsc-transport.js"; +import { resolveInvalidRscCacheBustingRequest } from "./app-rsc-cache-busting.js"; import { finalizeMissingStaticAssetResponse, resolveStaticAssetSignal } from "./worker-utils.js"; import { cloneRequestWithHeaders, @@ -90,7 +95,16 @@ async function handleRequest( registerConfiguredCacheAdapters(env as Record | undefined); registerConfiguredImageOptimizer(env as Record | undefined); - const url = new URL(request.url); + let url = new URL(request.url); + if (resolveRscTransportRoutePathname(url.pathname) !== null) { + const redirect = await resolveInvalidRscCacheBustingRequest({ + isRscRequest: true, + request, + }); + if (redirect) return redirect; + } + request = resolveRscTransportRequest(request, url); + url = new URL(request.url); if (isImageOptimizationPath(url.pathname) && env?.ASSETS && getImageOptimizer()) { const assetFetcher = env.ASSETS; diff --git a/packages/vinext/src/server/app-rsc-cache-busting.ts b/packages/vinext/src/server/app-rsc-cache-busting.ts index 31e1bba9d..1d097718f 100644 --- a/packages/vinext/src/server/app-rsc-cache-busting.ts +++ b/packages/vinext/src/server/app-rsc-cache-busting.ts @@ -4,6 +4,10 @@ import { parseAppRscRenderMode, type AppRscRenderMode, } from "./app-rsc-render-mode.js"; +import { + createRscTransportRequestPathname, + isCloudflareRscTransportEnabled, +} from "./app-rsc-transport.js"; import { NEXT_ROUTER_PREFETCH_HEADER, NEXT_ROUTER_SEGMENT_PREFETCH_HEADER, @@ -325,6 +329,9 @@ export async function createRscRequestUrl(href: string, headers: Headers): Promi const url = new URL(toRscRequestPath(href), "http://vinext.local"); const hash = await computeRscCacheBustingSearchParam(headers); setRscCacheBustingSearchParam(url, hash); + if (isCloudflareRscTransportEnabled()) { + url.pathname = createRscTransportRequestPathname(url.pathname, headers); + } return `${url.pathname}${url.search}`; } diff --git a/packages/vinext/src/server/app-rsc-transport.ts b/packages/vinext/src/server/app-rsc-transport.ts new file mode 100644 index 000000000..10fb50a4f --- /dev/null +++ b/packages/vinext/src/server/app-rsc-transport.ts @@ -0,0 +1,80 @@ +import { APP_RSC_RENDER_MODE_NAVIGATION } from "./app-rsc-render-mode.js"; +import { + NEXT_ROUTER_PREFETCH_HEADER, + NEXT_ROUTER_SEGMENT_PREFETCH_HEADER, + NEXT_ROUTER_STATE_TREE_HEADER, + NEXT_URL_HEADER, + VINEXT_CLIENT_REUSE_MANIFEST_HEADER, + VINEXT_INTERCEPTION_CONTEXT_HEADER, + VINEXT_MOUNTED_SLOTS_HEADER, + VINEXT_RSC_RENDER_MODE_HEADER, +} from "./headers.js"; + +export const VINEXT_STATIC_RSC_TRANSPORT_PREFIX = "/_next/static/__vinext/prerendered-rsc"; +export const VINEXT_WORKER_RSC_TRANSPORT_PREFIX = "/__vinext/rsc"; + +const ROOT_RSC_TRANSPORT_FILE = "__root.rsc"; +const TRAILING_SLASH_RSC_TRANSPORT_FILE = "__index.rsc"; + +export function isCloudflareRscTransportEnabled(): boolean { + return process.env.__VINEXT_CLOUDFLARE_RSC_TRANSPORT === "true"; +} + +function isStaticRscTransportEligible(headers: Headers): boolean { + const renderMode = headers.get(VINEXT_RSC_RENDER_MODE_HEADER); + return ( + !headers.has(NEXT_ROUTER_PREFETCH_HEADER) && + !headers.has(NEXT_ROUTER_SEGMENT_PREFETCH_HEADER) && + !headers.has(NEXT_ROUTER_STATE_TREE_HEADER) && + !headers.has(NEXT_URL_HEADER) && + !headers.has(VINEXT_CLIENT_REUSE_MANIFEST_HEADER) && + !headers.has(VINEXT_INTERCEPTION_CONTEXT_HEADER) && + !headers.has(VINEXT_MOUNTED_SLOTS_HEADER) && + (renderMode === null || renderMode === APP_RSC_RENDER_MODE_NAVIGATION) + ); +} + +export function createRscTransportAssetPathname(routePathname: string): string { + if (routePathname === "/") return `/${ROOT_RSC_TRANSPORT_FILE}`; + if (!routePathname.startsWith("/")) { + throw new Error(`Invalid RSC transport route pathname: ${routePathname}`); + } + if (routePathname.endsWith("/")) { + return `${routePathname}${TRAILING_SLASH_RSC_TRANSPORT_FILE}`; + } + return `${routePathname}.rsc`; +} + +export function createRscTransportRequestPathname(routePathname: string, headers: Headers): string { + const prefix = isStaticRscTransportEligible(headers) + ? VINEXT_STATIC_RSC_TRANSPORT_PREFIX + : VINEXT_WORKER_RSC_TRANSPORT_PREFIX; + return `${prefix}${createRscTransportAssetPathname(routePathname)}`; +} + +function stripTransportPrefix(pathname: string, prefix: string): string | null { + if (pathname === prefix) return ""; + return pathname.startsWith(`${prefix}/`) ? pathname.slice(prefix.length) : null; +} + +export function resolveRscTransportRoutePathname(pathname: string): string | null { + const assetPathname = + stripTransportPrefix(pathname, VINEXT_STATIC_RSC_TRANSPORT_PREFIX) ?? + stripTransportPrefix(pathname, VINEXT_WORKER_RSC_TRANSPORT_PREFIX); + if (assetPathname === null) return null; + if (assetPathname === `/${ROOT_RSC_TRANSPORT_FILE}`) return "/"; + if (assetPathname.endsWith(`/${TRAILING_SLASH_RSC_TRANSPORT_FILE}`)) { + return assetPathname.slice(0, -TRAILING_SLASH_RSC_TRANSPORT_FILE.length); + } + if (!assetPathname.endsWith(".rsc")) return null; + const routePathname = assetPathname.slice(0, -4); + return routePathname.startsWith("/") && routePathname.length > 1 ? routePathname : null; +} + +export function resolveRscTransportRequest(request: Request, url = new URL(request.url)): Request { + const routePathname = resolveRscTransportRoutePathname(url.pathname); + if (routePathname === null) return request; + + const mappedUrl = `${url.protocol}//${url.host}${routePathname}${url.search}`; + return new Request(mappedUrl, request); +} diff --git a/tests/app-rsc-cache-busting.test.ts b/tests/app-rsc-cache-busting.test.ts index 2e68af8d0..7495caa15 100644 --- a/tests/app-rsc-cache-busting.test.ts +++ b/tests/app-rsc-cache-busting.test.ts @@ -20,6 +20,10 @@ import { APP_RSC_RENDER_MODE_REFRESH_PRESERVE_UI, } from "../packages/vinext/src/server/app-rsc-render-mode.js"; import { VINEXT_CLIENT_REUSE_MANIFEST_HEADER } from "../packages/vinext/src/server/headers.js"; +import { + VINEXT_STATIC_RSC_TRANSPORT_PREFIX, + VINEXT_WORKER_RSC_TRANSPORT_PREFIX, +} from "../packages/vinext/src/server/app-rsc-transport.js"; import { fnv1a64 } from "../packages/vinext/src/utils/hash.js"; import { withEnvVar } from "./env-test-helpers.js"; @@ -56,6 +60,53 @@ describe("App Router RSC cache-busting", () => { ); }); + it("uses the static RSC transport path for plain Cloudflare RSC navigations", async () => { + const headers = createRscRequestHeaders(); + + await withEnvVar("__VINEXT_CLOUDFLARE_RSC_TRANSPORT", "true", async () => { + await expect(createRscRequestUrl("/dashboard?tab=activity", headers)).resolves.toBe( + `${VINEXT_STATIC_RSC_TRANSPORT_PREFIX}/dashboard.rsc?tab=activity&_rsc`, + ); + await expect(createRscRequestUrl("/", headers)).resolves.toBe( + `${VINEXT_STATIC_RSC_TRANSPORT_PREFIX}/__root.rsc?_rsc`, + ); + }); + }); + + it("uses the Worker RSC transport path for Cloudflare variant RSC requests", async () => { + const headers = createRscRequestHeaders({ + interceptionContext: "/feed", + renderMode: APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL, + }); + const hash = await computeRscCacheBustingSearchParam(headers); + + await withEnvVar("__VINEXT_CLOUDFLARE_RSC_TRANSPORT", "true", async () => { + await expect(createRscRequestUrl("/photos/42", headers)).resolves.toBe( + `${VINEXT_WORKER_RSC_TRANSPORT_PREFIX}/photos/42.rsc?${VINEXT_RSC_CACHE_BUSTING_SEARCH_PARAM}=${hash}`, + ); + }); + }); + + it("keeps stale Cloudflare transport RSC redirects on the transport path", async () => { + const headers = createRscRequestHeaders({ + renderMode: APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL, + }); + const hash = await computeRscCacheBustingSearchParam(headers); + const request = new Request( + `https://example.com${VINEXT_WORKER_RSC_TRANSPORT_PREFIX}/photos/42.rsc?_rsc=stale`, + { headers }, + ); + + const response = await resolveInvalidRscCacheBustingRequest({ + isRscRequest: true, + request, + }); + + expect(response?.headers.get("Location")).toBe( + `${VINEXT_WORKER_RSC_TRANSPORT_PREFIX}/photos/42.rsc?${VINEXT_RSC_CACHE_BUSTING_SEARCH_PARAM}=${hash}`, + ); + }); + it("uses the canonical route URL for root RSC navigations", async () => { // Ported from Next.js: test/e2e/app-dir/navigation/navigation.test.ts // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/navigation/navigation.test.ts diff --git a/tests/cloudflare-prerender-assets.test.ts b/tests/cloudflare-prerender-assets.test.ts index 8d5c907f8..6ba15c919 100644 --- a/tests/cloudflare-prerender-assets.test.ts +++ b/tests/cloudflare-prerender-assets.test.ts @@ -11,6 +11,12 @@ import { writePrerenderIndex, type PrerenderRouteResult, } from "../packages/vinext/src/build/prerender.js"; +import { + createRscTransportAssetPathname, + resolveRscTransportRequest, + VINEXT_STATIC_RSC_TRANSPORT_PREFIX, + VINEXT_WORKER_RSC_TRANSPORT_PREFIX, +} from "../packages/vinext/src/server/app-rsc-transport.js"; const tempRoots: string[] = []; @@ -78,8 +84,12 @@ afterEach(() => { } }); +function staticRscAssetPath(routePathname: string): string { + return `${VINEXT_STATIC_RSC_TRANSPORT_PREFIX}${createRscTransportAssetPathname(routePathname)}`; +} + describe("publishCloudflarePrerenderedAppAssets", () => { - it("publishes fully static App Router prerender artifacts into Cloudflare assets", async () => { + it("publishes static App Router HTML at visible paths and RSC in the transport namespace", async () => { const root = createTempRoot(); const serverDir = path.join(root, "dist/server"); const prerenderDir = path.join(serverDir, "prerendered-routes"); @@ -124,15 +134,20 @@ describe("publishCloudflarePrerenderedAppAssets", () => { serverDir, }); - expect(result).toEqual({ skipped: false, publishedFiles: 3, publishedRoutes: 2 }); + expect(result).toEqual({ skipped: false, publishedFiles: 4, publishedRoutes: 2 }); expect(fs.readFileSync(path.join(clientDir, "index.html"), "utf-8")).toBe("

Home

"); expect(fs.readFileSync(path.join(clientDir, "about"), "utf-8")).toBe("

About

"); - expect(fs.readFileSync(path.join(clientDir, "about.rsc"), "utf-8")).toBe("about-rsc"); - expect(fs.existsSync(path.join(clientDir, "index.rsc"))).toBe(false); + expect(fs.existsSync(path.join(clientDir, "about.rsc"))).toBe(false); + expect(fs.readFileSync(path.join(clientDir, `.${staticRscAssetPath("/")}`), "utf-8")).toBe( + "home-rsc", + ); + expect(fs.readFileSync(path.join(clientDir, `.${staticRscAssetPath("/about")}`), "utf-8")).toBe( + "about-rsc", + ); expect(fs.existsSync(path.join(clientDir, "isr"))).toBe(false); expect(fs.existsSync(path.join(clientDir, "404"))).toBe(false); expect(fs.readFileSync(path.join(clientDir, "taken"), "utf-8")).toBe("

Existing asset

"); - expect(fs.existsSync(path.join(clientDir, "taken.rsc"))).toBe(false); + expect(fs.existsSync(path.join(clientDir, `.${staticRscAssetPath("/taken")}`))).toBe(false); expect(fs.existsSync(path.join(clientDir, "pages-home"))).toBe(false); const headers = fs.readFileSync(path.join(clientDir, "_headers"), "utf-8"); @@ -142,7 +157,7 @@ describe("publishCloudflarePrerenderedAppAssets", () => { expect(headers).toContain(" X-Vinext-Cache: STATIC"); expect(headers).toContain(" x-nextjs-cache: HIT"); expect(headers).toContain(" Link: ; rel=preload"); - expect(headers).toContain("/about.rsc\n Content-Type: text/x-component"); + expect(headers).toContain(`${staticRscAssetPath("/about")}\n Content-Type: text/x-component`); expect(headers).toContain(" X-Vinext-RSC-Compatibility-Id: rsc-compat-test"); expect(headers).toContain(" x-deployment-id: deploy-test"); }); @@ -210,10 +225,28 @@ describe("publishCloudflarePrerenderedAppAssets", () => { expect(result).toEqual({ skipped: false, publishedFiles: 0, publishedRoutes: 0 }); expect(fs.existsSync(path.join(clientDir, "about"))).toBe(false); expect(fs.existsSync(path.join(clientDir, "about.rsc"))).toBe(false); + expect(fs.existsSync(path.join(clientDir, `.${staticRscAssetPath("/about")}`))).toBe(false); expect(fs.existsSync(path.join(clientDir, "_headers"))).toBe(false); }); - it("publishes RSC assets only when the RSC query-invariance proof is present", async () => { + it("maps static and Worker RSC transport requests back to visible routes", () => { + const staticRequest = resolveRscTransportRequest( + new Request(`https://example.test${staticRscAssetPath("/about")}?tab=1&_rsc`, { + headers: { RSC: "1" }, + }), + ); + expect(new URL(staticRequest.url).pathname).toBe("/about"); + expect(new URL(staticRequest.url).search).toBe("?tab=1&_rsc"); + + const workerRequest = resolveRscTransportRequest( + new Request(`https://example.test${VINEXT_WORKER_RSC_TRANSPORT_PREFIX}/docs/__index.rsc`, { + headers: { RSC: "1" }, + }), + ); + expect(new URL(workerRequest.url).pathname).toBe("/docs/"); + }); + + it("publishes HTML without static RSC when only the HTML query-invariance proof is present", async () => { const root = createTempRoot(); const serverDir = path.join(root, "dist/server"); const prerenderDir = path.join(serverDir, "prerendered-routes"); @@ -236,14 +269,10 @@ describe("publishCloudflarePrerenderedAppAssets", () => { expect(result).toEqual({ skipped: false, publishedFiles: 1, publishedRoutes: 1 }); expect(fs.readFileSync(path.join(clientDir, "about"), "utf-8")).toBe("

About

"); - expect(fs.existsSync(path.join(clientDir, "about.rsc"))).toBe(false); - - const headers = fs.readFileSync(path.join(clientDir, "_headers"), "utf-8"); - expect(headers).toContain("/about\n Content-Type: text/html; charset=utf-8"); - expect(headers).not.toContain("/about.rsc"); + expect(fs.existsSync(path.join(clientDir, `.${staticRscAssetPath("/about")}`))).toBe(false); }); - it("skips HTML publication when an RSC target already exists without RSC proof", async () => { + it("skips HTML publication when the reserved RSC transport target already exists", async () => { const root = createTempRoot(); const serverDir = path.join(root, "dist/server"); const prerenderDir = path.join(serverDir, "prerendered-routes"); @@ -251,7 +280,7 @@ describe("publishCloudflarePrerenderedAppAssets", () => { writeWrangler(serverDir); writeFile(path.join(prerenderDir, "about.html"), "

About

"); writeFile(path.join(prerenderDir, "about.rsc"), "about-rsc"); - writeFile(path.join(clientDir, "about.rsc"), "existing-user-rsc-asset"); + writeFile(path.join(clientDir, `.${staticRscAssetPath("/about")}`), "existing-rsc-asset"); const result = publishCloudflarePrerenderedAppAssets({ config: await baseConfig(), @@ -267,8 +296,8 @@ describe("publishCloudflarePrerenderedAppAssets", () => { expect(result).toEqual({ skipped: false, publishedFiles: 0, publishedRoutes: 0 }); expect(fs.existsSync(path.join(clientDir, "about"))).toBe(false); - expect(fs.readFileSync(path.join(clientDir, "about.rsc"), "utf-8")).toBe( - "existing-user-rsc-asset", + expect(fs.readFileSync(path.join(clientDir, `.${staticRscAssetPath("/about")}`), "utf-8")).toBe( + "existing-rsc-asset", ); expect(fs.existsSync(path.join(clientDir, "_headers"))).toBe(false); }); From 918d58b24e015e6be2f8ad5ad0bb017afff4f7c6 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 4 Jul 2026 02:22:38 +1000 Subject: [PATCH 6/7] fix(cloudflare): make static app asset publication respect Workers headers 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. --- .../src/build/cloudflare-prerender-assets.ts | 115 ++++++++------ .../build/cloudflare-static-assets-config.ts | 144 ++++++++++++++++++ packages/vinext/src/index.ts | 77 +--------- tests/cloudflare-prerender-assets.test.ts | 138 ++++++++++++++++- tests/cloudflare-rsc-transport-config.test.ts | 79 ++++++++++ 5 files changed, 436 insertions(+), 117 deletions(-) create mode 100644 packages/vinext/src/build/cloudflare-static-assets-config.ts create mode 100644 tests/cloudflare-rsc-transport-config.test.ts diff --git a/packages/vinext/src/build/cloudflare-prerender-assets.ts b/packages/vinext/src/build/cloudflare-prerender-assets.ts index 62507539a..7b26e1837 100644 --- a/packages/vinext/src/build/cloudflare-prerender-assets.ts +++ b/packages/vinext/src/build/cloudflare-prerender-assets.ts @@ -1,5 +1,10 @@ import fs from "node:fs"; import path from "node:path"; +import { + isCloudflareRscTransportAllowedForAssetsConfig, + readEmittedWranglerAssetsConfig, + readRootWranglerAssetsConfig, +} from "./cloudflare-static-assets-config.js"; import type { ResolvedNextConfig } from "../config/next-config.js"; import { createValidFileMatcher } from "../routing/file-matcher.js"; import { @@ -15,14 +20,8 @@ import { STATIC_CACHE_CONTROL } from "../server/cache-control.js"; import { NEXTJS_CACHE_HEADER, VINEXT_CACHE_HEADER } from "../server/headers.js"; import { NEXT_DEPLOYMENT_ID_HEADER } from "../utils/deployment-id.js"; import { getOutputPath, getRscOutputPath } from "../utils/prerender-output-paths.js"; -import { isUnknownRecord } from "../utils/record.js"; import type { PrerenderRouteResult } from "./prerender.js"; -type WranglerAssetsConfig = { - directory: string; - notFoundHandling?: string; -}; - export type PublishCloudflarePrerenderedAppAssetsResult = | { publishedFiles: number; @@ -39,27 +38,22 @@ export type PublishCloudflarePrerenderedAppAssetsResult = const GENERATED_HEADERS_START = "# Static prerendered App Router assets (generated by vinext)"; const GENERATED_HEADERS_END = "# End static prerendered App Router assets"; -function readWranglerAssetsConfig(serverDir: string): WranglerAssetsConfig | null { - const wranglerPath = path.join(serverDir, "wrangler.json"); - if (!fs.existsSync(wranglerPath)) return null; - - let parsed: unknown; - try { - parsed = JSON.parse(fs.readFileSync(wranglerPath, "utf-8")); - } catch { - return null; - } - - if (!isUnknownRecord(parsed) || !isUnknownRecord(parsed.assets)) return null; - const directory = parsed.assets.directory; - if (typeof directory !== "string" || directory.length === 0) return null; - - const notFoundHandling = parsed.assets.not_found_handling; - return { - directory, - notFoundHandling: typeof notFoundHandling === "string" ? notFoundHandling : undefined, - }; -} +const HTML_AUTHORITATIVE_HEADERS = [ + "Content-Type", + "Cache-Control", + VINEXT_CACHE_HEADER, + NEXTJS_CACHE_HEADER, +]; + +const RSC_AUTHORITATIVE_HEADERS = [ + "Content-Type", + "Cache-Control", + "Vary", + VINEXT_CACHE_HEADER, + NEXTJS_CACHE_HEADER, + VINEXT_RSC_COMPATIBILITY_ID_HEADER, + NEXT_DEPLOYMENT_ID_HEADER, +]; function hasMiddlewareOrProxy(root: string, config: ResolvedNextConfig): boolean { const matcher = createValidFileMatcher(config.pageExtensions); @@ -112,8 +106,16 @@ function copyIfAbsent( return "copied"; } -function headerBlockForPath(pathname: string, headers: Record): string[] { +function headerBlockForPath(options: { + detachHeaders?: readonly string[]; + headers: Record; + pathname: string; +}): string[] { + const { detachHeaders = [], headers, pathname } = options; const lines = [pathname]; + for (const name of detachHeaders) { + lines.push(` ! ${name}`); + } for (const [name, value] of Object.entries(headers)) { lines.push(` ${name}: ${value}`); } @@ -135,7 +137,11 @@ function removeGeneratedHeadersBlock(content: string): string { function writeGeneratedHeadersBlock( clientDir: string, - entries: Array<{ headers: Record; pathname: string }>, + entries: Array<{ + detachHeaders?: readonly string[]; + headers: Record; + pathname: string; + }>, ): void { const headersPath = path.join(clientDir, "_headers"); const existing = fs.existsSync(headersPath) ? fs.readFileSync(headersPath, "utf-8") : ""; @@ -151,7 +157,7 @@ function writeGeneratedHeadersBlock( const generated = [ GENERATED_HEADERS_START, - ...entries.flatMap((entry) => headerBlockForPath(entry.pathname, entry.headers)), + ...entries.flatMap((entry) => headerBlockForPath(entry)), GENERATED_HEADERS_END, "", ].join("\n"); @@ -265,8 +271,21 @@ export function publishCloudflarePrerenderedAppAssets(options: { }; } - const assetsConfig = readWranglerAssetsConfig(options.serverDir); - if (!assetsConfig) { + const rootAssetsConfig = readRootWranglerAssetsConfig(options.root, process.env.CLOUDFLARE_ENV); + if ( + !rootAssetsConfig.ok || + !isCloudflareRscTransportAllowedForAssetsConfig(rootAssetsConfig.assets) + ) { + return { + skipped: true, + reason: "Cloudflare RSC transport is disabled for the selected Wrangler environment", + publishedFiles: 0, + publishedRoutes: 0, + }; + } + + const assetsConfig = readEmittedWranglerAssetsConfig(options.serverDir); + if (!assetsConfig.ok || !assetsConfig.assets?.directory) { return { skipped: true, reason: "Cloudflare assets binding not found", @@ -274,7 +293,7 @@ export function publishCloudflarePrerenderedAppAssets(options: { publishedRoutes: 0, }; } - if (assetsConfig.notFoundHandling && assetsConfig.notFoundHandling !== "none") { + if (!isCloudflareRscTransportAllowedForAssetsConfig(assetsConfig.assets)) { return { skipped: true, reason: "Cloudflare assets not_found_handling is not none", @@ -283,9 +302,14 @@ export function publishCloudflarePrerenderedAppAssets(options: { }; } - const clientDir = path.resolve(options.serverDir, assetsConfig.directory); + const clientDir = path.resolve(options.serverDir, assetsConfig.assets.directory); const routes = eligibleStaticAppRoutes(options.routes); - const headerEntries: Array<{ headers: Record; pathname: string }> = []; + const headerEntries: Array<{ + detachHeaders?: readonly string[]; + headers: Record; + pathname: string; + }> = []; + let publishedStaticRsc = false; let publishedFiles = 0; let publishedRoutes = 0; @@ -320,23 +344,17 @@ export function publishCloudflarePrerenderedAppAssets(options: { routePublished = true; headerEntries.push({ pathname: routePathname, + detachHeaders: HTML_AUTHORITATIVE_HEADERS, headers: htmlHeaders, }); } if (shouldPublishRsc) { const rscSource = path.join(options.prerenderDir, getRscOutputPath(routePathname)); - const rscHeaders = buildRscHeaders({ - deploymentId: options.config.deploymentId, - rscCompatibilityId: options.rscCompatibilityId, - }); if (copyIfAbsent(rscSource, rscTarget) === "copied") { publishedFiles++; routePublished = true; - headerEntries.push({ - pathname: rscAssetPathname, - headers: rscHeaders, - }); + publishedStaticRsc = true; } } @@ -345,6 +363,17 @@ export function publishCloudflarePrerenderedAppAssets(options: { } } + if (publishedStaticRsc) { + headerEntries.push({ + pathname: `${VINEXT_STATIC_RSC_TRANSPORT_PREFIX}/*`, + detachHeaders: RSC_AUTHORITATIVE_HEADERS, + headers: buildRscHeaders({ + deploymentId: options.config.deploymentId, + rscCompatibilityId: options.rscCompatibilityId, + }), + }); + } + writeGeneratedHeadersBlock(clientDir, headerEntries); return { skipped: false, publishedFiles, publishedRoutes }; } diff --git a/packages/vinext/src/build/cloudflare-static-assets-config.ts b/packages/vinext/src/build/cloudflare-static-assets-config.ts new file mode 100644 index 000000000..6ba16c92b --- /dev/null +++ b/packages/vinext/src/build/cloudflare-static-assets-config.ts @@ -0,0 +1,144 @@ +import fs from "node:fs"; +import path from "node:path"; +import { isUnknownRecord } from "../utils/record.js"; + +export type WranglerAssetsConfig = { + directory?: string; + notFoundHandling?: string; +}; + +export type WranglerAssetsConfigReadResult = + | { assets: WranglerAssetsConfig | null; ok: true } + | { ok: false }; + +function stripJsonComments(source: string): string { + let output = ""; + let inString = false; + let escaped = false; + + for (let index = 0; index < source.length; index++) { + const char = source[index]; + const next = source[index + 1]; + + if (inString) { + output += char; + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === '"') { + inString = false; + } + continue; + } + + if (char === '"') { + inString = true; + output += char; + continue; + } + + if (char === "/" && next === "/") { + while (index < source.length && source[index] !== "\n") index++; + output += "\n"; + continue; + } + + if (char === "/" && next === "*") { + index += 2; + while (index < source.length && !(source[index] === "*" && source[index + 1] === "/")) { + output += source[index] === "\n" ? "\n" : " "; + index++; + } + index++; + continue; + } + + output += char; + } + return output; +} + +function parseJsonOrJsonc(source: string): unknown { + try { + return JSON.parse(source); + } catch { + return JSON.parse(stripJsonComments(source).replace(/,\s*([}\]])/g, "$1")); + } +} + +function toAssetsConfig(value: unknown): WranglerAssetsConfigReadResult { + if (value === undefined) return { ok: true, assets: null }; + if (!isUnknownRecord(value)) return { ok: false }; + + const directory = value.directory; + const notFoundHandling = value.not_found_handling; + return { + ok: true, + assets: { + directory: typeof directory === "string" && directory.length > 0 ? directory : undefined, + notFoundHandling: typeof notFoundHandling === "string" ? notFoundHandling : undefined, + }, + }; +} + +function readJsonWranglerConfig(filePath: string): unknown { + try { + return parseJsonOrJsonc(fs.readFileSync(filePath, "utf-8")); + } catch { + return undefined; + } +} + +function resolveEnvironmentAssetsConfig(parsed: unknown, envName: string | undefined): unknown { + if (!isUnknownRecord(parsed)) return undefined; + + const topLevelAssets = parsed.assets; + if (!envName || !isUnknownRecord(parsed.env)) return topLevelAssets; + + const envConfig = parsed.env[envName]; + if (!isUnknownRecord(envConfig) || !Object.hasOwn(envConfig, "assets")) { + return topLevelAssets; + } + + const envAssets = envConfig.assets; + if (isUnknownRecord(topLevelAssets) && isUnknownRecord(envAssets)) { + return { ...topLevelAssets, ...envAssets }; + } + return envAssets; +} + +export function readRootWranglerAssetsConfig( + root: string, + envName: string | undefined, +): WranglerAssetsConfigReadResult { + const wranglerPath = ["wrangler.jsonc", "wrangler.json"] + .map((filename) => path.join(root, filename)) + .find((candidate) => fs.existsSync(candidate)); + if (!wranglerPath) return { ok: true, assets: null }; + + const parsed = readJsonWranglerConfig(wranglerPath); + if (!isUnknownRecord(parsed)) return { ok: false }; + + return toAssetsConfig(resolveEnvironmentAssetsConfig(parsed, envName)); +} + +export function readEmittedWranglerAssetsConfig(serverDir: string): WranglerAssetsConfigReadResult { + const wranglerPath = path.join(serverDir, "wrangler.json"); + if (!fs.existsSync(wranglerPath)) return { ok: true, assets: null }; + + const parsed = readJsonWranglerConfig(wranglerPath); + if (!isUnknownRecord(parsed)) return { ok: false }; + + return toAssetsConfig(parsed.assets); +} + +export function isCloudflareRscTransportAllowedForAssetsConfig( + assetsConfig: WranglerAssetsConfig | null, +): boolean { + return ( + assetsConfig === null || + assetsConfig.notFoundHandling === undefined || + assetsConfig.notFoundHandling === "none" + ); +} diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 8401647b2..73b089302 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -112,6 +112,10 @@ import { import { PHASE_PRODUCTION_BUILD, PHASE_DEVELOPMENT_SERVER } from "vinext/shims/constants"; import { precompressAssets } from "./build/precompress.js"; import { ensureAssetsIgnore } from "./build/assets-ignore.js"; +import { + isCloudflareRscTransportAllowedForAssetsConfig, + readRootWranglerAssetsConfig, +} from "./build/cloudflare-static-assets-config.js"; import { emitNextClientRuntimeManifests } from "./build/next-client-runtime-manifests.js"; import { collectInlineCssManifest, injectInlineCssManifestGlobal } from "./build/inline-css.js"; import { validateDevRequest } from "./server/dev-origin-check.js"; @@ -1290,80 +1294,11 @@ function hasCloudflareVitePlugin(plugins: unknown): boolean { return flattenPluginCandidates(plugins).some(isCloudflareVitePlugin); } -function stripJsonComments(source: string): string { - let output = ""; - let inString = false; - let escaped = false; - for (let index = 0; index < source.length; index++) { - const char = source[index]; - const next = source[index + 1]; - - if (inString) { - output += char; - if (escaped) { - escaped = false; - } else if (char === "\\") { - escaped = true; - } else if (char === '"') { - inString = false; - } - continue; - } - - if (char === '"') { - inString = true; - output += char; - continue; - } - - if (char === "/" && next === "/") { - while (index < source.length && source[index] !== "\n") index++; - output += "\n"; - continue; - } - - if (char === "/" && next === "*") { - index += 2; - while (index < source.length && !(source[index] === "*" && source[index + 1] === "/")) { - output += source[index] === "\n" ? "\n" : " "; - index++; - } - index++; - continue; - } - - output += char; - } - return output; -} - -function parseJsonOrJsonc(source: string): unknown { - try { - return JSON.parse(source); - } catch { - return JSON.parse(stripJsonComments(source).replace(/,\s*([}\]])/g, "$1")); - } -} - function shouldEnableCloudflareRscTransport(root: string, plugins: unknown): boolean { if (!hasCloudflareVitePlugin(plugins)) return false; - const wranglerPath = ["wrangler.jsonc", "wrangler.json"] - .map((filename) => path.join(root, filename)) - .find((candidate) => fs.existsSync(candidate)); - if (!wranglerPath) return true; - - let parsed: unknown; - try { - parsed = parseJsonOrJsonc(fs.readFileSync(wranglerPath, "utf-8")); - } catch { - return false; - } - - if (!isRecord(parsed) || parsed.assets === undefined) return true; - if (!isRecord(parsed.assets)) return false; - const notFoundHandling = parsed.assets.not_found_handling; - return notFoundHandling === undefined || notFoundHandling === "none"; + const readResult = readRootWranglerAssetsConfig(root, process.env.CLOUDFLARE_ENV); + return readResult.ok && isCloudflareRscTransportAllowedForAssetsConfig(readResult.assets); } export default function vinext(options: VinextOptions = {}): PluginOption[] { diff --git a/tests/cloudflare-prerender-assets.test.ts b/tests/cloudflare-prerender-assets.test.ts index 6ba15c919..d9f16e957 100644 --- a/tests/cloudflare-prerender-assets.test.ts +++ b/tests/cloudflare-prerender-assets.test.ts @@ -7,16 +7,23 @@ import { type ResolvedNextConfig, } from "../packages/vinext/src/config/next-config.js"; import { publishCloudflarePrerenderedAppAssets } from "../packages/vinext/src/build/cloudflare-prerender-assets.js"; +import { + isCloudflareRscTransportAllowedForAssetsConfig, + readRootWranglerAssetsConfig, +} from "../packages/vinext/src/build/cloudflare-static-assets-config.js"; import { writePrerenderIndex, type PrerenderRouteResult, } from "../packages/vinext/src/build/prerender.js"; +import { STATIC_CACHE_CONTROL } from "../packages/vinext/src/server/cache-control.js"; +import { VINEXT_RSC_CONTENT_TYPE } from "../packages/vinext/src/server/app-rsc-cache-busting.js"; import { createRscTransportAssetPathname, resolveRscTransportRequest, VINEXT_STATIC_RSC_TRANSPORT_PREFIX, VINEXT_WORKER_RSC_TRANSPORT_PREFIX, } from "../packages/vinext/src/server/app-rsc-transport.js"; +import { withEnvVar } from "./env-test-helpers.js"; const tempRoots: string[] = []; @@ -88,6 +95,39 @@ function staticRscAssetPath(routePathname: string): string { return `${VINEXT_STATIC_RSC_TRANSPORT_PREFIX}${createRscTransportAssetPathname(routePathname)}`; } +function applyHeadersRules(content: string, pathname: string): Headers { + const headers = new Headers(); + let activePattern: string | null = null; + + for (const line of content.split(/\r?\n/)) { + if (line.trim().length === 0 || line.trimStart().startsWith("#")) continue; + + if (!/^\s/.test(line)) { + activePattern = line.trim(); + continue; + } + if (activePattern === null || !matchesHeaderRule(activePattern, pathname)) continue; + + const trimmed = line.trim(); + if (trimmed.startsWith("! ")) { + headers.delete(trimmed.slice(2)); + continue; + } + + const separator = trimmed.indexOf(":"); + if (separator === -1) continue; + headers.append(trimmed.slice(0, separator), trimmed.slice(separator + 1).trim()); + } + + return headers; +} + +function matchesHeaderRule(pattern: string, pathname: string): boolean { + if (!pattern.includes("*")) return pattern === pathname; + const [prefix, suffix] = pattern.split("*", 2); + return pathname.startsWith(prefix) && pathname.endsWith(suffix ?? ""); +} + describe("publishCloudflarePrerenderedAppAssets", () => { it("publishes static App Router HTML at visible paths and RSC in the transport namespace", async () => { const root = createTempRoot(); @@ -152,14 +192,31 @@ describe("publishCloudflarePrerenderedAppAssets", () => { const headers = fs.readFileSync(path.join(clientDir, "_headers"), "utf-8"); expect(headers).toContain("/_next/static/*"); - expect(headers).toContain("/\n Content-Type: text/html; charset=utf-8"); - expect(headers).toContain("/about\n Content-Type: text/html; charset=utf-8"); + expect(headers).toContain("/\n ! Content-Type\n ! Cache-Control"); + expect(headers).toContain("/about\n ! Content-Type\n ! Cache-Control"); + expect(headers).toContain(" Content-Type: text/html; charset=utf-8"); expect(headers).toContain(" X-Vinext-Cache: STATIC"); expect(headers).toContain(" x-nextjs-cache: HIT"); expect(headers).toContain(" Link: ; rel=preload"); - expect(headers).toContain(`${staticRscAssetPath("/about")}\n Content-Type: text/x-component`); + expect(headers).toContain( + `${VINEXT_STATIC_RSC_TRANSPORT_PREFIX}/*\n ! Content-Type\n ! Cache-Control`, + ); + expect(headers).not.toContain(`${staticRscAssetPath("/about")}\n`); + expect( + headers + .split(/\r?\n/) + .filter((line) => line.trim() === `${VINEXT_STATIC_RSC_TRANSPORT_PREFIX}/*`), + ).toHaveLength(1); expect(headers).toContain(" X-Vinext-RSC-Compatibility-Id: rsc-compat-test"); expect(headers).toContain(" x-deployment-id: deploy-test"); + + const regularStaticHeaders = applyHeadersRules(headers, "/_next/static/app.js"); + expect(regularStaticHeaders.get("Cache-Control")).toBe("public, max-age=31536000, immutable"); + + const rscHeaders = applyHeadersRules(headers, staticRscAssetPath("/about")); + expect(rscHeaders.get("Cache-Control")).toBe(STATIC_CACHE_CONTROL); + expect(rscHeaders.get("Content-Type")).toBe(VINEXT_RSC_CONTENT_TYPE); + expect(rscHeaders.get("Cache-Control")).not.toContain("immutable"); }); it("does not publish when middleware or config request transforms are present", async () => { @@ -302,6 +359,81 @@ describe("publishCloudflarePrerenderedAppAssets", () => { expect(fs.existsSync(path.join(clientDir, "_headers"))).toBe(false); }); + it("uses the selected Wrangler environment when gating static transport publication", async () => { + const root = createTempRoot(); + const serverDir = path.join(root, "dist/server"); + const prerenderDir = path.join(serverDir, "prerendered-routes"); + writeWrangler(serverDir); + writeFile( + path.join(root, "wrangler.jsonc"), + JSON.stringify({ + assets: { + binding: "ASSETS", + directory: "dist/client", + not_found_handling: "none", + }, + env: { + preview: { + assets: { + not_found_handling: "single-page-application", + }, + }, + }, + }), + ); + writeFile(path.join(prerenderDir, "about.html"), "

About

"); + writeFile(path.join(prerenderDir, "about.rsc"), "about-rsc"); + + await withEnvVar("CLOUDFLARE_ENV", "preview", async () => { + const result = publishCloudflarePrerenderedAppAssets({ + config: await baseConfig(), + prerenderDir, + root, + routes: [renderedAppRoute("/about", ["about.html", "about.rsc"])], + serverDir, + }); + + expect(result).toEqual({ + skipped: true, + reason: "Cloudflare RSC transport is disabled for the selected Wrangler environment", + publishedFiles: 0, + publishedRoutes: 0, + }); + }); + }); + + it("resolves env-specific Wrangler asset overrides for static transport gating", () => { + const root = createTempRoot(); + writeFile( + path.join(root, "wrangler.jsonc"), + `{ + // top-level production fallback is disabled for static RSC transport + "assets": { + "binding": "ASSETS", + "directory": "dist/client", + "not_found_handling": "single-page-application", + }, + "env": { + "production": { + "assets": { + "not_found_handling": "none", + }, + } + }, + }`, + ); + + const topLevel = readRootWranglerAssetsConfig(root, undefined); + const production = readRootWranglerAssetsConfig(root, "production"); + + expect(topLevel.ok && isCloudflareRscTransportAllowedForAssetsConfig(topLevel.assets)).toBe( + false, + ); + expect(production.ok && isCloudflareRscTransportAllowedForAssetsConfig(production.assets)).toBe( + true, + ); + }); + it("preserves query-invariance proof in the prerender manifest", () => { const root = createTempRoot(); diff --git a/tests/cloudflare-rsc-transport-config.test.ts b/tests/cloudflare-rsc-transport-config.test.ts new file mode 100644 index 000000000..e2f77f263 --- /dev/null +++ b/tests/cloudflare-rsc-transport-config.test.ts @@ -0,0 +1,79 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import vinext from "../packages/vinext/src/index.js"; +import { withEnvVar } from "./env-test-helpers.js"; + +const tempRoots: string[] = []; + +type VinextConfigPlugin = { + config?: ( + config: { plugins: unknown[]; root: string }, + env: { command: "build"; mode: "production" }, + ) => Promise<{ define: Record }> | { define: Record }; + name: string; +}; + +function createTempRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-cf-rsc-transport-config-")); + tempRoots.push(root); + fs.mkdirSync(path.join(root, "pages"), { recursive: true }); + fs.writeFileSync( + path.join(root, "pages/index.tsx"), + `export default function Home() { return

Home

; }\n`, + ); + return root; +} + +function findConfigPlugin(): VinextConfigPlugin { + const plugins = vinext() as VinextConfigPlugin[]; + const configPlugin = plugins.find( + (plugin) => plugin.name === "vinext:config" && typeof plugin.config === "function", + ); + if (!configPlugin) throw new Error("vinext:config plugin not found"); + return configPlugin; +} + +afterEach(() => { + for (const root of tempRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +describe("Cloudflare static RSC transport config", () => { + it("uses the selected Wrangler environment when defining client RSC transport", async () => { + const root = createTempRoot(); + fs.writeFileSync( + path.join(root, "wrangler.jsonc"), + JSON.stringify({ + assets: { + binding: "ASSETS", + directory: "dist/client", + not_found_handling: "none", + }, + env: { + preview: { + assets: { + not_found_handling: "single-page-application", + }, + }, + }, + }), + ); + + await withEnvVar("CLOUDFLARE_ENV", "preview", async () => { + const result = await findConfigPlugin().config!( + { + root, + plugins: [{ name: "vite-plugin-cloudflare" }], + }, + { command: "build", mode: "production" }, + ); + + expect(result.define["process.env.__VINEXT_CLOUDFLARE_RSC_TRANSPORT"]).toBe( + JSON.stringify("false"), + ); + }); + }); +}); From ee658ed3c2834afad8c3d9ea75e28b248c355829 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 4 Jul 2026 02:04:46 +1000 Subject: [PATCH 7/7] perf(cloudflare): publish unaffected static app routes with config transforms Cloudflare static App Router asset publication skipped the whole app whenever next.config headers, redirects, or rewrites existed. That was safe, but it made an unrelated /legacy redirect prevent /about from taking the static asset path. The skipped-app assumption was broader than the routing invariant requires: only a route whose visible pathname can match a request transform must remain Worker-owned. Reuse config source matching to skip affected routes while publishing routes whose source patterns cannot match. Request-dependent has/missing conditions still fail closed because a matching source skips publication before evaluating request state. --- .../src/build/cloudflare-prerender-assets.ts | 32 +++++----- packages/vinext/src/config/config-matchers.ts | 44 +++++++++++++ tests/cloudflare-prerender-assets.test.ts | 61 ++++++++++++++----- 3 files changed, 109 insertions(+), 28 deletions(-) diff --git a/packages/vinext/src/build/cloudflare-prerender-assets.ts b/packages/vinext/src/build/cloudflare-prerender-assets.ts index 7b26e1837..7a0b0c349 100644 --- a/packages/vinext/src/build/cloudflare-prerender-assets.ts +++ b/packages/vinext/src/build/cloudflare-prerender-assets.ts @@ -5,6 +5,11 @@ import { readEmittedWranglerAssetsConfig, readRootWranglerAssetsConfig, } from "./cloudflare-static-assets-config.js"; +import { + matchesHeaderSource, + matchesRedirectSource, + matchesRewriteSource, +} from "../config/config-matchers.js"; import type { ResolvedNextConfig } from "../config/next-config.js"; import { createValidFileMatcher } from "../routing/file-matcher.js"; import { @@ -70,13 +75,16 @@ function hasMiddlewareOrProxy(root: string, config: ResolvedNextConfig): boolean return false; } -function hasRequestTransformConfig(config: ResolvedNextConfig): boolean { +function isRouteAffectedByRequestTransformConfig( + routePathname: string, + config: ResolvedNextConfig, +): boolean { return ( - config.headers.length > 0 || - config.redirects.length > 0 || - config.rewrites.beforeFiles.length > 0 || - config.rewrites.afterFiles.length > 0 || - config.rewrites.fallback.length > 0 + config.headers.some((header) => matchesHeaderSource(routePathname, header)) || + config.redirects.some((redirect) => matchesRedirectSource(routePathname, redirect)) || + config.rewrites.beforeFiles.some((rewrite) => matchesRewriteSource(routePathname, rewrite)) || + config.rewrites.afterFiles.some((rewrite) => matchesRewriteSource(routePathname, rewrite)) || + config.rewrites.fallback.some((rewrite) => matchesRewriteSource(routePathname, rewrite)) ); } @@ -254,14 +262,6 @@ export function publishCloudflarePrerenderedAppAssets(options: { publishedRoutes: 0, }; } - if (hasRequestTransformConfig(options.config)) { - return { - skipped: true, - reason: "config headers, redirects, or rewrites require Worker routing", - publishedFiles: 0, - publishedRoutes: 0, - }; - } if (hasMiddlewareOrProxy(options.root, options.config)) { return { skipped: true, @@ -315,6 +315,10 @@ export function publishCloudflarePrerenderedAppAssets(options: { for (const route of routes) { const routePathname = route.path ?? route.route; + if (isRouteAffectedByRequestTransformConfig(routePathname, options.config)) { + continue; + } + const visibleAssetPath = safeVisibleAssetPathForRoute(routePathname); if (!visibleAssetPath) continue; diff --git a/packages/vinext/src/config/config-matchers.ts b/packages/vinext/src/config/config-matchers.ts index a2a8b818c..38b4c83e7 100644 --- a/packages/vinext/src/config/config-matchers.ts +++ b/packages/vinext/src/config/config-matchers.ts @@ -1077,6 +1077,25 @@ export function matchRedirect( return localeMatch; } +/** + * Check whether a redirect source can match a pathname without evaluating its + * request-dependent `has` / `missing` conditions. + * + * Build-time asset publication uses this as a conservative guard: if the + * source can match, the route must stay Worker-owned because request state may + * decide whether the redirect applies. + */ +export function matchesRedirectSource( + pathname: string, + redirect: NextRedirect, + basePathState: BasePathMatchState = _BASEPATH_DEFAULT, +): boolean { + return ( + shouldEvaluateRule(redirect.basePath, basePathState) && + matchConfigPattern(pathname, redirect.source) !== null + ); +} + /** * Apply rewrite rules from next.config.js. * Returns the rewritten URL or null if no rewrite matched. @@ -1517,6 +1536,31 @@ export function matchHeaders( return result; } +/** + * Check whether a custom-header source can match a pathname without evaluating + * request-dependent `has` / `missing` conditions. + * + * Uses the same header-source compiler as matchHeaders so publication + * eligibility follows runtime header matching semantics. + */ +export function matchesHeaderSource( + pathname: string, + header: NextHeader, + basePathState: BasePathMatchState = _BASEPATH_DEFAULT, +): boolean { + if (!shouldEvaluateRule(header.basePath, basePathState)) return false; + + const pathnameHadTrailingSlash = pathname.length > 1 && pathname.endsWith("/"); + pathname = stripTrailingSlashForConfigMatch(pathname); + const source = pathnameHadTrailingSlash + ? stripTrailingSlashForConfigMatch(header.source) + : header.source; + const sourceRegex = getCachedRegex(_compiledHeaderSourceCache, source, () => + safeRegExp("^" + escapeHeaderSource(source) + "$", "i"), + ); + return sourceRegex !== null && sourceRegex.test(pathname); +} + /** * Escape a string for inclusion in a regex character class / alternation. * Mirrors `escape-string-regexp` semantics used by Next.js's processRoutes. diff --git a/tests/cloudflare-prerender-assets.test.ts b/tests/cloudflare-prerender-assets.test.ts index d9f16e957..6258db873 100644 --- a/tests/cloudflare-prerender-assets.test.ts +++ b/tests/cloudflare-prerender-assets.test.ts @@ -219,7 +219,7 @@ describe("publishCloudflarePrerenderedAppAssets", () => { expect(rscHeaders.get("Cache-Control")).not.toContain("immutable"); }); - it("does not publish when middleware or config request transforms are present", async () => { + it("does not publish when middleware or proxy are present", async () => { const middlewareRoot = createTempRoot(); const middlewareServerDir = path.join(middlewareRoot, "dist/server"); const middlewarePrerenderDir = path.join(middlewareServerDir, "prerendered-routes"); @@ -237,25 +237,58 @@ describe("publishCloudflarePrerenderedAppAssets", () => { expect(middlewareResult).toMatchObject({ skipped: true, publishedFiles: 0 }); expect(fs.existsSync(path.join(middlewareRoot, "dist/client/about"))).toBe(false); + }); - const headersRoot = createTempRoot(); - const headersServerDir = path.join(headersRoot, "dist/server"); - const headersPrerenderDir = path.join(headersServerDir, "prerendered-routes"); - writeWrangler(headersServerDir); - writeFile(path.join(headersPrerenderDir, "about.html"), "

About

"); + it("skips only routes affected by next.config request transforms", async () => { + const root = createTempRoot(); + const serverDir = path.join(root, "dist/server"); + const prerenderDir = path.join(serverDir, "prerendered-routes"); + const clientDir = path.join(root, "dist/client"); + writeWrangler(serverDir); + writeFile(path.join(prerenderDir, "about.html"), "

About

"); + writeFile(path.join(prerenderDir, "about.rsc"), "about-rsc"); + writeFile(path.join(prerenderDir, "old.html"), "

Old

"); + writeFile(path.join(prerenderDir, "old.rsc"), "old-rsc"); + writeFile(path.join(prerenderDir, "docs/intro.html"), "

Docs

"); + writeFile(path.join(prerenderDir, "docs/intro.rsc"), "docs-rsc"); + writeFile(path.join(prerenderDir, "contact.html"), "

Contact

"); + writeFile(path.join(prerenderDir, "contact.rsc"), "contact-rsc"); - const headersResult = publishCloudflarePrerenderedAppAssets({ + const result = publishCloudflarePrerenderedAppAssets({ config: await baseConfig({ - headers: [{ source: "/about", headers: [{ key: "x-test", value: "1" }] }], + headers: [ + { + source: "/about", + has: [{ type: "cookie", key: "auth" }], + headers: [{ key: "x-test", value: "1" }], + }, + ], + redirects: [{ source: "/old", destination: "/new", permanent: false }], + rewrites: { + beforeFiles: [{ source: "/docs/:slug", destination: "/internal/:slug" }], + afterFiles: [], + fallback: [], + }, }), - prerenderDir: headersPrerenderDir, - root: headersRoot, - routes: [renderedAppRoute("/about", ["about.html"])], - serverDir: headersServerDir, + prerenderDir, + root, + routes: [ + renderedAppRoute("/about", ["about.html", "about.rsc"]), + renderedAppRoute("/old", ["old.html", "old.rsc"]), + renderedAppRoute("/docs/intro", ["docs/intro.html", "docs/intro.rsc"]), + renderedAppRoute("/contact", ["contact.html", "contact.rsc"]), + ], + serverDir, }); - expect(headersResult).toMatchObject({ skipped: true, publishedFiles: 0 }); - expect(fs.existsSync(path.join(headersRoot, "dist/client/about"))).toBe(false); + expect(result).toEqual({ skipped: false, publishedFiles: 2, publishedRoutes: 1 }); + expect(fs.existsSync(path.join(clientDir, "about"))).toBe(false); + expect(fs.existsSync(path.join(clientDir, "old"))).toBe(false); + expect(fs.existsSync(path.join(clientDir, "docs/intro"))).toBe(false); + expect(fs.readFileSync(path.join(clientDir, "contact"), "utf-8")).toBe("

Contact

"); + expect( + fs.readFileSync(path.join(clientDir, `.${staticRscAssetPath("/contact")}`), "utf-8"), + ).toBe("contact-rsc"); }); it("requires query-invariant prerender proof before publishing assets", async () => {