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..7a0b0c349 --- /dev/null +++ b/packages/vinext/src/build/cloudflare-prerender-assets.ts @@ -0,0 +1,383 @@ +import fs from "node:fs"; +import path from "node:path"; +import { + isCloudflareRscTransportAllowedForAssetsConfig, + 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 { + VINEXT_RSC_COMPATIBILITY_ID_HEADER, + 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"; +import { getOutputPath, getRscOutputPath } from "../utils/prerender-output-paths.js"; +import type { PrerenderRouteResult } from "./prerender.js"; + +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"; + +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); + 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 isRouteAffectedByRequestTransformConfig( + routePathname: string, + config: ResolvedNextConfig, +): boolean { + return ( + 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)) + ); +} + +function safeVisibleAssetPathForRoute(routePathname: string): string | null { + if (routePathname === "/") return "index"; + 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, +): "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 "copied"; +} + +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}`); + } + 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<{ + detachHeaders?: readonly string[]; + headers: Record; + pathname: string; + }>, +): void { + 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)), + 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.queryInvariant?.html === true && + 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 (hasMiddlewareOrProxy(options.root, options.config)) { + return { + skipped: true, + reason: "middleware/proxy must run before page responses", + publishedFiles: 0, + publishedRoutes: 0, + }; + } + + 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", + publishedFiles: 0, + publishedRoutes: 0, + }; + } + if (!isCloudflareRscTransportAllowedForAssetsConfig(assetsConfig.assets)) { + return { + skipped: true, + reason: "Cloudflare assets not_found_handling is not none", + publishedFiles: 0, + publishedRoutes: 0, + }; + } + + const clientDir = path.resolve(options.serverDir, assetsConfig.assets.directory); + const routes = eligibleStaticAppRoutes(options.routes); + const headerEntries: Array<{ + detachHeaders?: readonly string[]; + headers: Record; + pathname: string; + }> = []; + let publishedStaticRsc = false; + let publishedFiles = 0; + let publishedRoutes = 0; + + for (const route of routes) { + const routePathname = route.path ?? route.route; + if (isRouteAffectedByRequestTransformConfig(routePathname, options.config)) { + 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) === "copied") { + publishedFiles++; + routePublished = true; + headerEntries.push({ + pathname: routePathname, + detachHeaders: HTML_AUTHORITATIVE_HEADERS, + headers: htmlHeaders, + }); + } + + if (shouldPublishRsc) { + const rscSource = path.join(options.prerenderDir, getRscOutputPath(routePathname)); + if (copyIfAbsent(rscSource, rscTarget) === "copied") { + publishedFiles++; + routePublished = true; + publishedStaticRsc = true; + } + } + + if (routePublished) { + publishedRoutes++; + } + } + + 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/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/build/run-prerender.ts b/packages/vinext/src/build/run-prerender.ts index e4d640d9e..5725c2e57 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 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. * @@ -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 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/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index df441c332..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"; @@ -1269,6 +1273,34 @@ type NitroSetupContext = { }; }; +function isCloudflareVitePlugin(plugin: unknown): boolean { + return ( + plugin !== null && + typeof plugin === "object" && + "name" in plugin && + typeof plugin.name === "string" && + (plugin.name === "vite-plugin-cloudflare" || plugin.name.startsWith("vite-plugin-cloudflare:")) + ); +} + +function flattenPluginCandidates(plugins: unknown): unknown[] { + if (!Array.isArray(plugins)) return []; + return plugins.flatMap((plugin) => + Array.isArray(plugin) ? flattenPluginCandidates(plugin) : [plugin], + ); +} + +function hasCloudflareVitePlugin(plugins: unknown): boolean { + return flattenPluginCandidates(plugins).some(isCloudflareVitePlugin); +} + +function shouldEnableCloudflareRscTransport(root: string, plugins: unknown): boolean { + if (!hasCloudflareVitePlugin(plugins)) return false; + + const readResult = readRootWranglerAssetsConfig(root, process.env.CLOUDFLARE_ENV); + return readResult.ok && isCloudflareRscTransportAllowedForAssetsConfig(readResult.assets); +} + export default function vinext(options: VinextOptions = {}): PluginOption[] { assertSupportedViteVersion(); const prerenderConfig = normalizeVinextPrerenderConfig(options.prerender); @@ -1942,6 +1974,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 +2272,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-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/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/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/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 new file mode 100644 index 000000000..6258db873 --- /dev/null +++ b/tests/cloudflare-prerender-assets.test.ts @@ -0,0 +1,489 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + resolveNextConfig, + 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[] = []; + +function createTempRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-cf-prerender-assets-")); + tempRoots.push(root); + return root; +} + +function writeFile(filePath: string, contents: string | Buffer): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, contents); +} + +async function baseConfig( + overrides: Partial = {}, +): 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, + queryInvariant: { html: true, rsc: true }, + revalidate: false, + router: "app", + ...extra, + }; +} + +afterEach(() => { + for (const root of tempRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +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(); + 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: 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.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, `.${staticRscAssetPath("/taken")}`))).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\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( + `${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 proxy 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); + }); + + 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 result = publishCloudflarePrerenderedAppAssets({ + config: await baseConfig({ + 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, + 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(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 () => { + 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, `.${staticRscAssetPath("/about")}`))).toBe(false); + expect(fs.existsSync(path.join(clientDir, "_headers"))).toBe(false); + }); + + 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"); + 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, `.${staticRscAssetPath("/about")}`))).toBe(false); + }); + + 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"); + 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, `.${staticRscAssetPath("/about")}`), "existing-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, `.${staticRscAssetPath("/about")}`), "utf-8")).toBe( + "existing-rsc-asset", + ); + 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(); + + 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 }, + }); + }); +}); 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"), + ); + }); + }); +}); 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"); });