Skip to content
Draft
383 changes: 383 additions & 0 deletions packages/vinext/src/build/cloudflare-prerender-assets.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
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<string, string>;
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<string, string> {
return {
"Content-Type": contentType,
"Cache-Control": STATIC_CACHE_CONTROL,
[VINEXT_CACHE_HEADER]: "STATIC",
[NEXTJS_CACHE_HEADER]: "HIT",
};
}

function buildHtmlHeaders(route: Extract<PrerenderRouteResult, { status: "rendered" }>) {
return {
...staticCacheHeaders("text/html; charset=utf-8"),
...(route.headers?.link ? { Link: route.headers.link } : {}),
};
}

function buildRscHeaders(options: {
deploymentId: string | undefined;
rscCompatibilityId: string | undefined;
}): Record<string, string> {
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<Extract<PrerenderRouteResult, { status: "rendered" }>> {
return routes.filter((route): route is Extract<PrerenderRouteResult, { status: "rendered" }> => {
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<string, string>;
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 };
}
Loading
Loading