Skip to content
Closed
Show file tree
Hide file tree
Changes from 50 commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
79abe38
feat(build): add transport-neutral worker stages
james-elicx Aug 25, 2026
ad9fe26
feat(cache): admit responses within cacheable stages
james-elicx Sep 1, 2026
337115c
fix(cache): preserve staged prewarming semantics
james-elicx Sep 1, 2026
cfae9ec
fix(cache): preserve staged routing parity
james-elicx Sep 1, 2026
3b90c46
fix(cache): shape completed staged responses
james-elicx Sep 1, 2026
58f56d3
fix(cache): reconcile outer private policies
james-elicx Sep 1, 2026
365e79d
fix(cache): reconcile hybrid Pages outer policy
james-elicx Sep 1, 2026
d7b71bf
fix(cache): isolate request-stage cacheability
james-elicx Sep 1, 2026
7b089b9
fix(build): keep client asset import beside server entry
james-elicx Sep 1, 2026
e29ce05
fix(cache): preserve staged HEAD and probe semantics
james-elicx Sep 1, 2026
cc60853
fix(cache): purge Pages CDN entries after revalidation
james-elicx Sep 1, 2026
871e85c
test(cache): preserve staged execution context coverage
james-elicx Sep 1, 2026
b81aeaf
fix(cache): preserve staged request identity
james-elicx Sep 1, 2026
7f3c02b
fix(cache): preserve regenerated response metadata
james-elicx Sep 1, 2026
9570212
style(cache): format staged cache helpers
james-elicx Sep 1, 2026
26a8b2f
fix(cache): bypass shared stages after middleware request overrides
james-elicx Sep 2, 2026
d8aef7d
fix(cache): admit explicitly public Pages APIs
james-elicx Sep 2, 2026
3c5abef
fix(cache): preserve outer Pages cache policy
james-elicx Sep 2, 2026
cb143c4
refactor(cache): extract app request stage dispatch
james-elicx Sep 2, 2026
f6fb6f3
fix(build): classify both staged app graphs
james-elicx Sep 2, 2026
2e6e873
fix(cache): preserve staged response policy precedence
james-elicx Sep 2, 2026
77032d1
fix(cache): honor adapter-owned response policies
james-elicx Sep 2, 2026
a2ba36d
fix(cache): preserve staged pre-handler response headers
james-elicx Sep 2, 2026
d214996
test(cache): verify Pages pre-handler response headers
james-elicx Sep 2, 2026
0a61fc6
fix(cache): thread adapter policy through admission
james-elicx Sep 2, 2026
bcd7dab
fix(cache): preserve static stage reuse
james-elicx Sep 2, 2026
6f874e3
fix(cache): preserve normalized stage representations
james-elicx Sep 2, 2026
f2a072e
fix(cache): preserve Pages initial props policy
james-elicx Sep 2, 2026
4f5e566
fix(build): detect default initial props assignments
james-elicx Sep 2, 2026
8be9937
fix(cache): derive Pages policy ownership at runtime
james-elicx Sep 2, 2026
301ef7f
test(build): cover transport-neutral stage outputs
james-elicx Sep 2, 2026
216e695
test(cache): provide staged Pages runtime classification
james-elicx Sep 2, 2026
1f9007a
fix(cache): make draft transitions bypass shared stages
james-elicx Sep 2, 2026
8e77cba
fix(cache): keep cookie responses private after staged composition
james-elicx Sep 2, 2026
d857178
fix(cache): preserve staged late-response vetoes
james-elicx Sep 2, 2026
7913939
fix(cache): preserve only completed staged vetoes
james-elicx Sep 2, 2026
61b323d
fix(cache): preserve late request-stage policy
james-elicx Sep 2, 2026
244eee1
test(cache): align staged browser policy
james-elicx Sep 2, 2026
e3941ff
fix(cache): keep transport core independently valid
james-elicx Sep 2, 2026
516e812
fix(cache): preserve core admission semantics
james-elicx Sep 2, 2026
2f4ba86
fix(cache): verify multi-stage prerender readiness
james-elicx Sep 2, 2026
4989cd5
fix(cache): validate App response-stage readiness
james-elicx Sep 2, 2026
b9d50af
fix(cache): warm routes resolved by uncached stages
james-elicx Sep 2, 2026
9cac117
fix(cache): retain staged readiness version proof
james-elicx Sep 2, 2026
eee868c
fix(cache): confine Pages readiness credentials
james-elicx Sep 2, 2026
c68d7d4
fix(cache): preserve env-bound adapter registration
james-elicx Sep 2, 2026
fa50a5b
fix(cache): initialize configured adapters once
james-elicx Sep 2, 2026
e11bab6
fix(cache): preserve explicit data adapters
james-elicx Sep 2, 2026
0e3c84c
fix(cache): mark rewrite-resolved warm routes
james-elicx Sep 2, 2026
61b9b1b
fix(cache): mark middleware-resolved warm routes
james-elicx Sep 2, 2026
ef41ed0
fix(cache): exclude externally rewritten warm paths
james-elicx Sep 2, 2026
029e396
fix(cache): classify terminal request-stage probes
james-elicx Sep 2, 2026
e6b2271
fix(cache): encode probe route metadata
james-elicx Sep 2, 2026
5b65155
fix(cache): preserve staged request semantics
james-elicx Sep 2, 2026
7fa477e
fix(cache): authenticate Worker prerender state
james-elicx Sep 2, 2026
e219581
fix(cache): isolate request-stage cache adapters
james-elicx Sep 2, 2026
0ce5b6d
fix(cache): preserve staged Pages response boundaries
james-elicx Sep 2, 2026
9665720
fix(cache): probe request-stage terminal routes
james-elicx Sep 2, 2026
34922be
fix(cache): report resolved probe pathnames
james-elicx Sep 2, 2026
e46fe4d
fix(cache): bypass hybrid custom Documents
james-elicx Sep 2, 2026
1321ccb
fix(cache): preserve adapter policy probe coverage
james-elicx Sep 2, 2026
86fb9d4
fix(build): preserve prewarm state in response stages
james-elicx Sep 2, 2026
5a9f4c6
fix(cache): fail closed on wrapped Documents
james-elicx Sep 2, 2026
69029fd
fix(cache): bypass stale Pages previews
james-elicx Sep 2, 2026
3520ecd
fix(pages): keep invalid preview cleanup private
james-elicx Sep 2, 2026
9e5d0c2
docs(pages): explain stale preview cache policy
james-elicx Sep 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions knip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,10 @@ export default {
"src/server/app-page-element-builder.ts",
"src/server/app-hook-warning-suppression.ts",
"src/server/app-post-middleware-context.ts",
"src/server/app-request-stage-context.ts",
"src/server/app-request-stage-independent-entry.ts",
"src/server/app-request-context.ts",
"src/server/app-route-handler-middleware-context.ts",
"src/server/app-rsc-error-handler.ts",
"src/server/isr-cache.ts",
"src/server/rsc-stream-hints.ts",
Expand Down
4 changes: 4 additions & 0 deletions packages/cloudflare/src/cache/cdn-adapter.runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,10 @@ function formatCacheTag(tags: readonly string[]): string | null {

export class CloudflareCdnCacheAdapter implements CdnCacheAdapter {
readonly requiresCompletedResponseAdmission = true;
readonly responsePolicyHeaderNames = [
"CDN-Cache-Control",
"Cloudflare-CDN-Cache-Control",
] as const;
readonly responseVary = "verbatim" as const;

constructor(
Expand Down
12 changes: 12 additions & 0 deletions packages/vinext/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,18 @@
"types": "./dist/server/fetch-handler.d.ts",
"import": "./dist/server/fetch-handler.js"
},
"./server/multi-stage": {
"types": "./dist/server/multi-stage.d.ts",
"import": "./dist/server/multi-stage.js"
},
"./server/request-stage": {
"types": "./dist/server/request-stage.d.ts",
"import": "./dist/server/request-stage.js"
},
"./server/response-stage": {
"types": "./dist/server/response-stage.d.ts",
"import": "./dist/server/response-stage.js"
},
"./server/app-router-entry": {
"types": "./dist/server/app-router-entry.d.ts",
"import": "./dist/server/app-router-entry.js"
Expand Down
82 changes: 82 additions & 0 deletions packages/vinext/src/build/client-build-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,23 @@ export function isRscFrameworkModule(id: string): boolean {
return pkg !== null && (FRAMEWORK_PACKAGES as readonly string[]).includes(pkg);
}

/**
* Keep virtual entry ids out of emitted RSC chunk filenames.
*
* Rolldown's entries-aware chunk names can contain the `\\0` virtual-id marker,
* which is not a portable module-specifier or filesystem name. Preserve every
* other character and remove both the printable and actual-NUL forms.
*/
export function sanitizeRscChunkFileName(name: string): string {
const withoutVirtualMarkers = name.replaceAll("\\0", "");
const invalid = new Set(["<", ">", ":", '"', "/", "\\", "|", "?", "*"]);
let sanitized = "";
for (const character of withoutVirtualMarkers) {
sanitized += character.charCodeAt(0) <= 31 || invalid.has(character) ? "_" : character;
}
return sanitized;
}

/**
* Output config that isolates React (and the RSC flight runtime) into a
* dedicated "framework" chunk in the RSC server build. See
Expand All @@ -245,6 +262,7 @@ export function isRscFrameworkModule(id: string): boolean {
*/
export function createRscFrameworkChunkOutputConfig() {
return {
sanitizeFileName: sanitizeRscChunkFileName,
codeSplitting: {
groups: [
{
Expand Down Expand Up @@ -287,3 +305,67 @@ export function withBuildBundlerOptions(
): Partial<VinextBuildConfig> {
return { rolldownOptions: bundlerOptions };
}

type VinextBuildOutput = Exclude<
NonNullable<VinextBuildBundlerOptions["output"]>,
readonly unknown[]
>;
type VinextCodeSplittingConfig = Exclude<NonNullable<VinextBuildOutput["codeSplitting"]>, boolean>;
type ChunkFileNames = NonNullable<VinextBuildOutput["chunkFileNames"]>;
type ChunkFileNameFunction = Exclude<ChunkFileNames, string>;

/**
* Keep vinext modules partitioned by the stage entries that actually use them.
* Without an entry-aware catch-all, Rolldown may merge a small helper shared by
* request/response entries into a response-heavy chunk; importing that helper
* then evaluates React and renderer code on a request-stage cache hit.
*/
export function createMultiStageCodeSplittingConfig(
existing: VinextBuildOutput["codeSplitting"],
): VinextCodeSplittingConfig & { groups: NonNullable<VinextCodeSplittingConfig["groups"]> } {
const base = existing && typeof existing === "object" ? existing : {};
return {
...base,
groups: [
{
name: "vinext-stage-runtime",
test: /(?:^|[/\\])(?:packages[/\\]vinext[/\\]src|(?:packages[/\\]vinext|node_modules[/\\](?:\.pnpm[/\\][^/\\]+[/\\]node_modules[/\\])?vinext)[/\\]dist)[/\\]/,
entriesAware: true,
},
...(base.groups ?? []),
],
};
}

/**
* Keep router stage chunks beside the server entry so their generated
* `./vinext-client-assets.js` external continues to resolve. Other chunks keep
* the host's existing output pattern (or vinext's server-assets default).
*/
export function createMultiStageChunkFileNames(
assetsDir: string,
existing: VinextBuildOutput["chunkFileNames"],
): ChunkFileNameFunction {
return (chunk) => {
const name = sanitizeRscChunkFileName(chunk.name);
if (
chunk.moduleIds?.some((id) =>
/\/server\/app-ssr-entry\.[cm]?[jt]sx?$/.test(toSlash(id.split("?", 1)[0] ?? "")),
) ||
[
"app-router-entry",
"pages-router-entry",
"app-response-stage-entry",
"pages-request-stage-entry",
"pages-response-stage-entry",
"virtual_vinext-rsc-entry",
"virtual_vinext-response-stage",
].some((entryName) => name.includes(entryName))
) {
return `${name}-[hash].js`;
}
if (typeof existing === "function") return existing({ ...chunk, name });
const pattern = existing ?? joinAssetFileNamePattern(assetsDir, "[name]-[hash].js");
return pattern.replaceAll("[name]", name);
};
}
80 changes: 61 additions & 19 deletions packages/vinext/src/build/prerender-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Server as HttpServer } from "node:http";
import {
loadNextConfig,
resolveNextConfig,
type NextRewrite,
type ResolvedNextConfig,
} from "../config/next-config.js";
import {
Expand Down Expand Up @@ -40,6 +41,7 @@ import { extractLocaleFromUrl, normalizeDefaultLocalePathname } from "../server/
import { normalizePathTrailingSlash } from "vinext/shims/url-utils";
import { buildPagesDataHref } from "vinext/shims/internal/pages-data-url";
import { CACHEABILITY_POLICY_HEADERS } from "vinext/shims/cacheability-classification";
import { resolveBuiltRscEntryPath } from "./server-entry.js";

export type PrerenderRoutePattern = {
kind: "app-page" | "app-route" | "pages-page";
Expand All @@ -49,9 +51,10 @@ export type PrerenderRoutePattern = {
canPrunePattern: boolean;
/** HTML pathname shared by alternate representations of this route. */
concretePathname?: string;
/** The uncached request stage may resolve this public path to another route. */
routeMayResolve?: boolean;
};
};

export type PrerenderPathManifest = {
/** App Page HTML paths after hybrid route ownership has been resolved. */
appPaths?: string[];
Expand Down Expand Up @@ -125,6 +128,7 @@ type EmitPrerenderPathManifestOptions = {
rscBundlePath?: string;
buildIdentity?: CdnCacheAdapterCapabilities["buildIdentity"];
responseVary?: CdnCacheAdapterCapabilities["responseVary"];
requestRouting?: CdnCacheAdapterCapabilities["requestRouting"];
/** Execute dynamic path hooks against an already-uploaded Worker. */
pathDiscoveryTarget?: {
baseUrl: string;
Expand Down Expand Up @@ -1077,6 +1081,7 @@ function routePatternCouldIntersectCachePolicyRule(
function annotateCacheabilityProbeSafety(
routePatterns: Record<string, PrerenderRoutePattern>,
config: Pick<ResolvedNextConfig, "basePath" | "headers" | "i18n" | "trailingSlash">,
routeMayResolve: ReadonlySet<string>,
): Record<string, PrerenderRoutePattern> {
const cachePolicyRules = config.headers.filter((rule) =>
rule.headers.some((header) => CACHEABILITY_POLICY_HEADER_NAMES.has(header.key.toLowerCase())),
Expand Down Expand Up @@ -1123,19 +1128,20 @@ function annotateCacheabilityProbeSafety(
pathname,
{
...route,
cacheabilityProbe: { canPrunePattern },
cacheabilityProbe: {
canPrunePattern,
...(routeMayResolve.has(pathname) ? { routeMayResolve: true } : {}),
Comment thread
james-elicx marked this conversation as resolved.
},
},
];
}),
);
}

function configuredRouteAffectsWarmPath(
function configuredRulesAffectWarmPath(
pathname: string,
config: Pick<
ResolvedNextConfig,
"basePath" | "i18n" | "redirects" | "rewrites" | "trailingSlash"
>,
rules: ReadonlyArray<NextRewrite>,
config: Pick<ResolvedNextConfig, "basePath" | "i18n" | "trailingSlash">,
): boolean {
const canonicalPathname = normalizePathTrailingSlash(pathname, config.trailingSlash);
const hostnames = [undefined, ...(config.i18n?.domains?.map((domain) => domain.domain) ?? [])];
Expand All @@ -1144,12 +1150,7 @@ function configuredRouteAffectsWarmPath(
normalizeDefaultLocalePathname(canonicalPathname, config.i18n, { hostname }),
),
);
const rewrites = [
...config.rewrites.beforeFiles,
...config.rewrites.afterFiles,
...config.rewrites.fallback,
];
return [...rewrites, ...config.redirects].some((rule) =>
return rules.some((rule) =>
Array.from(matchPathnames).some((matchPathname) =>
matchesRewriteSource(matchPathname, rule, {
basePath: config.basePath,
Expand All @@ -1159,6 +1160,22 @@ function configuredRouteAffectsWarmPath(
);
}

function hasMiddlewareConventionFile(
root: string,
appDir: string | null,
pagesDir: string | null,
pageExtensions: readonly string[],
): boolean {
const routeDir = appDir ?? pagesDir;
const routeRoot = routeDir ? path.dirname(routeDir) : root;
const conventionDir = routeRoot === path.join(root, "src") ? routeRoot : root;
return ["proxy", "middleware"].some((name) =>
pageExtensions.some((extension) =>
fs.existsSync(path.join(conventionDir, `${name}.${extension}`)),
),
);
}

async function startPathDiscoveryServer(options: {
serverDir: string;
pagesBundlePath?: string;
Expand Down Expand Up @@ -1187,9 +1204,10 @@ export async function emitPrerenderPathManifest(

if (!appDir && !pagesDir) return null;

const defaultRscBundlePath = options.routeRootConfig?.rscOutDir
? path.join(path.resolve(root, options.routeRootConfig.rscOutDir), "index.js")
: path.join(root, "dist", "server", "index.js");
const rscServerDir = options.routeRootConfig?.rscOutDir
? path.resolve(root, options.routeRootConfig.rscOutDir)
: path.join(root, "dist", "server");
const defaultRscBundlePath = resolveBuiltRscEntryPath(rscServerDir);
const rscBundlePath = options.rscBundlePath ?? defaultRscBundlePath;
const pagesBundlePath = options.pagesBundlePath ?? path.join(root, "dist", "server", "entry.js");
const bundleServerDir = fs.existsSync(rscBundlePath)
Expand Down Expand Up @@ -1321,10 +1339,30 @@ export async function emitPrerenderPathManifest(
}
});

const configuredRewrites = [
...config.rewrites.beforeFiles,
...config.rewrites.afterFiles,
...config.rewrites.fallback,
];
const middlewareMayResolveWarmPaths =
options.requestRouting === "uncached-stage" &&
hasMiddlewareConventionFile(root, appDir, pagesDir, config.pageExtensions);
const routeMayResolveWarmPathSet = new Set(
options.requestRouting === "uncached-stage"
? [...paths, ...discoveredRouteHandlerPaths].filter(
(pathname) =>
middlewareMayResolveWarmPaths ||
configuredRulesAffectWarmPath(pathname, configuredRewrites, config),
)
: [],
);
const excludedWarmPathSet = new Set(
options.responseVary
? [...paths, ...discoveredRouteHandlerPaths].filter((pathname) =>
configuredRouteAffectsWarmPath(pathname, config),
? [...paths, ...discoveredRouteHandlerPaths].filter(
(pathname) =>
configuredRulesAffectWarmPath(pathname, config.redirects, config) ||
(options.requestRouting !== "uncached-stage" &&
configuredRulesAffectWarmPath(pathname, configuredRewrites, config)),
)
: [],
);
Expand Down Expand Up @@ -1382,7 +1420,11 @@ export async function emitPrerenderPathManifest(
"",
),
);
const routePatterns = annotateCacheabilityProbeSafety(appOwnedWarmPaths.routePatterns, config);
const routePatterns = annotateCacheabilityProbeSafety(
appOwnedWarmPaths.routePatterns,
config,
routeMayResolveWarmPathSet,
);
for (let index = 0; index < resolvedPagesDataWarmPaths.length; index++) {
const route = routePatterns[resolvedPagesDataWarmPaths[index]];
if (route) {
Expand Down
13 changes: 8 additions & 5 deletions packages/vinext/src/build/run-prerender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { injectPregeneratedConcretePaths } from "./inject-pregenerated-paths.js"
import { rememberCurrentServerEntryImportMtime, startProdServer } from "../server/prod-server.js";
import { enterPrerenderPhase } from "./prerender-phase.js";
import { PHASE_PRODUCTION_BUILD } from "vinext/shims/constants";
import { resolveBuiltRscEntryPath } from "./server-entry.js";

// ─── Progress UI ──────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -101,7 +102,8 @@ type RunPrerenderOptions = {
pagesBundlePath?: string;
/**
* Override the path to the App Router RSC bundle.
* Defaults to `<root>/dist/server/index.js`.
* Defaults to the App handler recorded in the server build manifest, or
* `<root>/dist/server/index.js` for builds without a manifest.
* Intended for tests that build to a custom outDir.
*/
rscBundlePath?: string;
Expand All @@ -121,9 +123,8 @@ type RunPrerenderOptions = {
* to stderr/stdout. Returns the full PrerenderResult so callers can pass it to
* printBuildReport.
*
* Works for both plain Node and Cloudflare Workers builds — the CF Workers
* bundle outputs `dist/server/index.js` which is a standard Node server entry,
* so no wrangler/miniflare is needed.
* Works for both plain Node and hosted builds by resolving the generated App
* handler independently of any deployment facade that owns `index.js`.
*
* Hybrid projects (both `app/` and `pages/` present) run both prerender
* phases sharing a single prod server instance. The merged results are written
Expand Down Expand Up @@ -167,7 +168,8 @@ export async function runPrerender(options: RunPrerenderOptions): Promise<Preren
// cleaned with the rest of vinext's build output on rebuild and co-located
// with server artifacts.
const manifestDir = path.join(root, "dist", "server");
const rscBundlePath = options.rscBundlePath ?? path.join(root, "dist", "server", "index.js");
const rscBundlePath =
options.rscBundlePath ?? resolveBuiltRscEntryPath(path.join(root, "dist", "server"));
const serverDir = path.dirname(rscBundlePath);

const config = options.nextConfig
Expand Down Expand Up @@ -235,6 +237,7 @@ export async function runPrerender(options: RunPrerenderOptions): Promise<Preren
port: 0,
host: "127.0.0.1",
outDir: path.dirname(serverDir),
rscEntryPath: rscBundlePath,
noCompression: true,
purpose: "prerender",
});
Expand Down
31 changes: 31 additions & 0 deletions packages/vinext/src/build/server-entry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import fs from "node:fs";
import path from "pathslash";

const RSC_ENTRY_MANIFEST_KEY = "virtual:vinext-rsc-entry";

/** Resolve the built App handler even when a deployment host owns index.js. */
export function resolveBuiltRscEntryPath(serverDir: string): string {
const fallbackPath = path.join(serverDir, "index.js");
const manifestPath = path.join(serverDir, ".vite", "manifest.json");
let manifest: unknown;
try {
manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
return fallbackPath;
}
if (error instanceof SyntaxError) return fallbackPath;
throw error;
}

if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) return fallbackPath;
const entry = Reflect.get(manifest, RSC_ENTRY_MANIFEST_KEY);
if (!entry || typeof entry !== "object") return fallbackPath;
const file = Reflect.get(entry, "file");
if (typeof file !== "string") return fallbackPath;

const entryPath = path.resolve(serverDir, file);
const relativePath = path.relative(serverDir, entryPath);
if (relativePath.startsWith("../") || path.isAbsolute(relativePath)) return fallbackPath;
return fs.existsSync(entryPath) ? entryPath : fallbackPath;
}
Loading
Loading