Skip to content
393 changes: 393 additions & 0 deletions packages/vinext/src/build/cloudflare-prerender-assets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,393 @@
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 {
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 { isUnknownRecord } from "../utils/record.js";
import type { PrerenderRouteResult } from "./prerender.js";
import { getRouteHandlerAssetOutputPath } from "./static-route-handler-assets.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 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(pathname: string, headers: Record<string, string>): 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<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.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<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 buildRouteHandlerHeaders(
route: Extract<PrerenderRouteResult, { status: "rendered" }>,
): Record<string, string> {
const contentType = route.headers?.["content-type"] ?? route.headers?.["Content-Type"];
return {
...(contentType ? { "Content-Type": contentType } : {}),
"Cache-Control": STATIC_CACHE_CONTROL,
[VINEXT_CACHE_HEADER]: "STATIC",
[NEXTJS_CACHE_HEADER]: "HIT",
};
}

function eligibleStaticAppPageRoutes(
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.appRouteKind !== "route-handler" &&
route.revalidate === false &&
route.queryInvariant?.html === true &&
route.fallback !== true &&
routePathname !== "/404" &&
routePathname !== "/500"
);
});
}

function eligibleStaticAppRouteHandlerRoutes(
routes: readonly PrerenderRouteResult[],
): Array<Extract<PrerenderRouteResult, { status: "rendered" }>> {
return routes.filter(
(route): route is Extract<PrerenderRouteResult, { status: "rendered" }> =>
route.status === "rendered" &&
route.router === "app" &&
route.appRouteKind === "route-handler" &&
route.revalidate === false,
);
}

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 headerEntries: Array<{ headers: Record<string, string>; pathname: string }> = [];
let publishedFiles = 0;
let publishedRoutes = 0;

for (const route of eligibleStaticAppPageRoutes(options.routes)) {
const routePathname = route.path ?? route.route;
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,
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,
});
}
}

if (routePublished) {
publishedRoutes++;
}
}

for (const route of eligibleStaticAppRouteHandlerRoutes(options.routes)) {
const routePathname = route.path ?? route.route;
const assetPath = getRouteHandlerAssetOutputPath(routePathname);
if (!assetPath || !route.outputFiles.includes(assetPath)) continue;

const targetPath = path.join(clientDir, assetPath);
if (fs.existsSync(targetPath)) continue;

if (copyIfAbsent(path.join(options.prerenderDir, assetPath), targetPath) === "copied") {
publishedFiles++;
publishedRoutes++;
headerEntries.push({
pathname: routePathname,
headers: buildRouteHandlerHeaders(route),
});
}
}

writeGeneratedHeadersBlock(clientDir, headerEntries);
return { skipped: false, publishedFiles, publishedRoutes };
}
Loading
Loading