Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4132539
fix(cache): gate CDN admission on probed routes
james-elicx Aug 26, 2026
73e3d99
fix(cache): admit no-manifest responses after clean EOF
james-elicx Aug 26, 2026
104c90f
fix(cache): fail closed for contextual admission
james-elicx Aug 26, 2026
cafd121
fix(cache): isolate conditional config headers
james-elicx Aug 26, 2026
6504ce3
test(cache): use a valid legacy revalidate fixture
james-elicx Aug 26, 2026
725eab2
fix(cache): finalize private app page evaluations
james-elicx Aug 26, 2026
a6ea94e
test(cache): cover RSC manifest admission
james-elicx Aug 26, 2026
48e8162
fix(cache): veto late private response policy
james-elicx Aug 26, 2026
ca7b2ec
fix(cache): reject conditional middleware probes
james-elicx Aug 26, 2026
eac9ad3
fix(cache): trust framework admission policy
james-elicx Aug 26, 2026
9c00047
fix(cache): isolate request-conditioned CDN responses
james-elicx Aug 26, 2026
c739b6b
fix(cache): avoid cookie response variation
james-elicx Aug 26, 2026
a571d16
fix(cache): preserve hybrid Pages admission policy
james-elicx Aug 26, 2026
9f7de18
test(cache): allow existing Pages warm fills
james-elicx Aug 26, 2026
92aa5ce
fix(cache): complete cacheable route handler responses
james-elicx Aug 26, 2026
db8340b
fix(cache): preserve final public page policy
james-elicx Aug 27, 2026
0b2374c
fix(cache): complete adapter-admitted route handlers
james-elicx Aug 27, 2026
e8929dd
fix(cache): preserve route handler opt-out headers
james-elicx Aug 27, 2026
6946d43
fix(cache): preserve explicit route handler policy
james-elicx Aug 27, 2026
f50e5a1
style(cache): format Route Handler admission
james-elicx Aug 27, 2026
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/cloudflare/src/cache/cdn-adapter.runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,8 @@ function formatCacheTag(tags: readonly string[]): string | null {
}

export class CloudflareCdnCacheAdapter implements CdnCacheAdapter {
readonly requiresCompletedResponseAdmission = true;

constructor(
private readonly versionMetadata?: WorkerVersionMetadata,
private readonly versionMetadataBinding = DEFAULT_VERSION_METADATA_BINDING,
Expand Down Expand Up @@ -237,6 +239,14 @@ export class CloudflareCdnCacheAdapter implements CdnCacheAdapter {
}

buildResponseHeaders(input: CdnCacheableHeaderInput): CdnResponseHeaders {
// App Page MISS streams may discover request-bound dynamic APIs after the
// response object is created. Only the outer Worker admission boundary may
// replace this private policy after clean EOF; without that proof, the CDN
// must fail closed.
if (input.pendingDynamicCheck) {
return clearCloudflareCdnResponseHeaders(NO_STORE);
}

// No cacheable policy → nobody stores it.
if (!input.cacheControl) {
return clearCloudflareCdnResponseHeaders(NO_STORE);
Expand Down
8 changes: 8 additions & 0 deletions packages/vinext/src/config/config-matchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -835,6 +835,7 @@ export function matchRedirect(
redirects: NextRedirect[],
ctx: RequestContext,
basePathState: BasePathMatchState = _BASEPATH_DEFAULT,
onRuleSourceMatch?: (rule: NextRedirect) => void,
): { destination: string; permanent: boolean } | null {
if (redirects.length === 0) return null;

Expand Down Expand Up @@ -875,6 +876,7 @@ export function matchRedirect(
if (entry.originalIndex >= localeMatchIndex) continue; // already have a better match
const redirect = entry.redirect;
if (!shouldEvaluateRule(redirect.basePath, basePathState)) continue;
onRuleSourceMatch?.(redirect);
const conditionParams =
redirect.has || redirect.missing
? collectConditionParams(redirect.has, redirect.missing, ctx)
Expand Down Expand Up @@ -906,6 +908,7 @@ export function matchRedirect(
if (!entry.altRe.test(localePart)) continue;
const redirect = entry.redirect;
if (!shouldEvaluateRule(redirect.basePath, basePathState)) continue;
onRuleSourceMatch?.(redirect);
const conditionParams =
redirect.has || redirect.missing
? collectConditionParams(redirect.has, redirect.missing, ctx)
Expand Down Expand Up @@ -936,6 +939,7 @@ export function matchRedirect(
if (!shouldEvaluateRule(redirect.basePath, basePathState)) continue;
const params = matchConfigPattern(pathname, redirect.source);
if (params) {
onRuleSourceMatch?.(redirect);
const conditionParams =
redirect.has || redirect.missing
? collectConditionParams(redirect.has, redirect.missing, ctx)
Expand Down Expand Up @@ -968,11 +972,13 @@ export function matchRewrite(
ctx: RequestContext,
basePathState: BasePathMatchState = _BASEPATH_DEFAULT,
paramsPathname: string = pathname,
onRuleSourceMatch?: (rule: NextRewrite) => void,
): string | null {
for (const rewrite of rewrites) {
if (!shouldEvaluateRule(rewrite.basePath, basePathState)) continue;
const matchedParams = matchConfigPattern(pathname, rewrite.source);
if (matchedParams) {
onRuleSourceMatch?.(rewrite);
// App request routing matches against a segment-normalized pathname but
// Next.js prepareDestination substitutes the encoded source captures.
// Prefer those captures when the caller retained the encoded pathname.
Expand Down Expand Up @@ -1384,6 +1390,7 @@ export function matchHeaders(
headers: NextHeader[],
ctx: RequestContext,
basePathState: BasePathMatchState = _BASEPATH_DEFAULT,
onRuleSourceMatch?: (rule: NextHeader) => void,
): Array<{ key: string; value: string }> {
const pathnameHadTrailingSlash = pathname.length > 1 && pathname.endsWith("/");
pathname = stripTrailingSlashForConfigMatch(pathname);
Expand All @@ -1400,6 +1407,7 @@ export function matchHeaders(
safeRegExp("^" + escapeHeaderSource(source) + "$", "i"),
);
if (sourceRegex && sourceRegex.test(pathname)) {
onRuleSourceMatch?.(rule);
if (rule.has || rule.missing) {
if (!checkHasConditions(rule.has, rule.missing, ctx)) {
continue;
Expand Down
2 changes: 2 additions & 0 deletions packages/vinext/src/entries/app-rsc-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,8 @@ import ${JSON.stringify(appRouteRequestBuiltInsPath)};`
: ""
}
import ${JSON.stringify(serverGlobalsPath)};
import __cacheabilityManifest from "virtual:vinext-cacheability-manifest";
export { __cacheabilityManifest };
import {
renderToReadableStream as _renderToReadableStream,
${
Expand Down
25 changes: 25 additions & 0 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1099,6 +1099,9 @@ const RESOLVED_PAGES_CLIENT_ASSETS = VIRTUAL_PREFIX + VIRTUAL_PAGES_CLIENT_ASSET
// Virtual module IDs for App Router entries
const VIRTUAL_RSC_ENTRY = "virtual:vinext-rsc-entry";
const RESOLVED_RSC_ENTRY = VIRTUAL_PREFIX + VIRTUAL_RSC_ENTRY;
const VIRTUAL_CACHEABILITY_MANIFEST = "virtual:vinext-cacheability-manifest";
const RESOLVED_CACHEABILITY_MANIFEST = VIRTUAL_PREFIX + VIRTUAL_CACHEABILITY_MANIFEST;
const CACHEABILITY_MANIFEST_MODULE = "__vinext_cacheability_manifest.js";
const VIRTUAL_APP_SSR_ENTRY = "virtual:vinext-app-ssr-entry";
const RESOLVED_APP_SSR_ENTRY = VIRTUAL_PREFIX + VIRTUAL_APP_SSR_ENTRY;
const VIRTUAL_APP_BROWSER_ENTRY = "virtual:vinext-app-browser-entry";
Expand Down Expand Up @@ -3901,6 +3904,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
}
// App Router virtual modules
if (cleanId === VIRTUAL_RSC_ENTRY) return RESOLVED_RSC_ENTRY;
if (cleanId === VIRTUAL_CACHEABILITY_MANIFEST) {
if (this.environment?.name === "rsc" && this.environment.config?.command === "build") {
return { id: `./${CACHEABILITY_MANIFEST_MODULE}`, external: true };
}
return RESOLVED_CACHEABILITY_MANIFEST;
}
if (cleanId === VIRTUAL_APP_SSR_ENTRY) return RESOLVED_APP_SSR_ENTRY;
if (cleanId === VIRTUAL_APP_BROWSER_ENTRY) return RESOLVED_APP_BROWSER_ENTRY;
if (cleanId === VIRTUAL_APP_CAPABILITIES) return RESOLVED_APP_CAPABILITIES;
Expand Down Expand Up @@ -4005,6 +4014,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
return `export default ${JSON.stringify(metadata)};`;
}
// App Router virtual modules
if (id === RESOLVED_CACHEABILITY_MANIFEST) {
return "export default null;";
}
if (id === RESOLVED_RSC_ENTRY && hasAppDir) {
const routes = await appRouter(appDir, nextConfig?.pageExtensions, fileMatcher);
const metaRoutes = scanMetadataFiles(appDir);
Expand Down Expand Up @@ -4393,6 +4405,19 @@ export const loadServerActionClient = ${
return null;
},
},
{
name: "vinext:cacheability-manifest-asset",
apply: "build",

generateBundle() {
if (this.environment?.name !== "rsc") return;
this.emitFile({
type: "asset",
fileName: CACHEABILITY_MANIFEST_MODULE,
source: "export default null;\n",
});
},
},
{
name: "vinext:css-url-assets-restore",
enforce: "post",
Expand Down
37 changes: 33 additions & 4 deletions packages/vinext/src/server/app-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,17 @@ export type ApplyAppMiddlewareResult =
cleanPathname: string;
/** Present on real middleware results; optional for older generated callers. */
matched?: boolean;
/** True when this pathname can match middleware for some request context. */
pathnameEligible?: boolean;
rewritten: boolean;
search: string | null;
}
| {
kind: "response";
/** Present on real middleware results; optional for older generated callers. */
matched?: boolean;
/** True when this pathname can match middleware for some request context. */
pathnameEligible?: boolean;
response: Response;
};

Expand Down Expand Up @@ -275,6 +279,7 @@ export async function applyAppMiddleware(
): Promise<ApplyAppMiddlewareResult> {
const forwarded = applyForwardedMiddlewareContext(options.request, options.context);
let matched = forwarded.applied;
let pathnameEligible = forwarded.applied;
let cleanPathname = options.cleanPathname;
let rewritten = false;
let search: string | null = null;
Expand All @@ -288,6 +293,7 @@ export async function applyAppMiddleware(
return {
kind: "response",
matched,
pathnameEligible,
response: validationResponseWithMiddlewareHeaders(validationResponse, options.context),
};
}
Expand All @@ -296,6 +302,7 @@ export async function applyAppMiddleware(
return {
kind: "response",
matched,
pathnameEligible,
response: await proxyExternalMiddlewareRewrite(
externalRequest,
forwarded.rewriteUrl,
Expand Down Expand Up @@ -335,6 +342,9 @@ export async function applyAppMiddleware(
onMatch() {
matched = true;
},
onPathMatch() {
pathnameEligible = true;
},
requestBodyAlreadyIsolated: true,
request: middlewareRequest,
trailingSlash: options.trailingSlash,
Expand All @@ -349,12 +359,22 @@ export async function applyAppMiddleware(
if (!result.continue) {
cancelRequestBody(options.request);
if (result.redirectUrl) {
return { kind: "response", matched, response: responseFromMiddlewareRedirect(result) };
return {
kind: "response",
matched,
pathnameEligible,
response: responseFromMiddlewareRedirect(result),
};
}
if (result.response) {
return { kind: "response", matched, response: result.response };
return { kind: "response", matched, pathnameEligible, response: result.response };
}
return { kind: "response", matched, response: internalServerErrorResponse() };
return {
kind: "response",
matched,
pathnameEligible,
response: internalServerErrorResponse(),
};
}

if (result.responseHeaders) {
Expand All @@ -375,13 +395,15 @@ export async function applyAppMiddleware(
return {
kind: "response",
matched,
pathnameEligible,
response: validationResponseWithMiddlewareHeaders(validationResponse, options.context),
};
}
const externalRequest = options.externalRewriteRequest ?? options.request;
return {
kind: "response",
matched,
pathnameEligible,
response: await proxyExternalMiddlewareRewrite(
externalRequest,
result.rewriteUrl,
Expand All @@ -408,5 +430,12 @@ export async function applyAppMiddleware(
processMiddlewareHeaders(options.context.headers);
}

return { kind: "continue", cleanPathname, matched, rewritten, search };
return {
kind: "continue",
cleanPathname,
matched,
pathnameEligible,
rewritten,
search,
};
}
43 changes: 29 additions & 14 deletions packages/vinext/src/server/app-page-cache-finalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ import { readStreamAsText } from "../utils/text-stream.js";
import { markFrameworkLinkHeaders } from "./app-response-header-provenance.js";
import { deferUntilStreamConsumed } from "./defer-until-stream-consumed.js";
import {
captureRouteCacheabilityResponsePolicy,
deferRouteCacheability,
isRouteCacheabilityProbe,
isRouteCacheabilityEvaluation,
type RouteCacheabilityOutcome,
} from "vinext/shims/cacheability-classification";

Expand All @@ -43,6 +44,16 @@ type BuildAppPageCacheRenderObservation = (input: {
state: AppPageRenderObservationState;
}) => RenderObservation;

type FinalizeAppPageCacheabilityEvaluationOptions = {
capturedDynamicUsageBeforeContextCleanup?: () => boolean;
consumeDynamicUsage: () => boolean;
consumeRenderObservationState?: () => AppPageRenderObservationState;
getPageTags: () => string[];
getRequestCacheLife?: () => AppPageRequestCacheLife | null;
expireSeconds?: number;
revalidateSeconds: number | null;
};

type FinalizeAppPageHtmlCacheResponseOptions = {
capturedDynamicUsageBeforeContextCleanup?: () => boolean;
capturedRscDataPromise: Promise<ArrayBuffer> | null;
Expand Down Expand Up @@ -175,21 +186,14 @@ function appPageCacheControlHeader(cacheControl: CacheControlMetadata): string {
: buildRevalidateCacheControl(cacheControl.revalidate, cacheControl.expire);
}

function finalizeProbeAppPageResponse(
function finalizeEvaluatedAppPageResponse(
response: Response,
options: {
capturedDynamicUsageBeforeContextCleanup?: () => boolean;
consumeDynamicUsage: () => boolean;
consumeRenderObservationState?: () => AppPageRenderObservationState;
getPageTags: () => string[];
getRequestCacheLife?: () => AppPageRequestCacheLife | null;
expireSeconds?: number;
revalidateSeconds: number | null;
},
options: FinalizeAppPageCacheabilityEvaluationOptions,
): Response | null {
if (!isRouteCacheabilityProbe()) return null;
if (!isRouteCacheabilityEvaluation()) return null;
const complete = deferRouteCacheability();
if (!complete) return response;
captureRouteCacheabilityResponsePolicy(response.headers);

let completed = false;
const finish = (): void => {
Expand Down Expand Up @@ -240,11 +244,22 @@ function finalizeProbeAppPageResponse(
});
}

/**
* Complete probe/admission classification for an App Page response that does
* not enter the ISR cache-write path. Ordinary requests pass through unchanged.
*/
export function finalizeAppPageCacheabilityEvaluationResponse(
response: Response,
options: FinalizeAppPageCacheabilityEvaluationOptions,
): Response {
return finalizeEvaluatedAppPageResponse(response, options) ?? response;
}

export function finalizeAppPageHtmlCacheResponse(
response: Response,
options: FinalizeAppPageHtmlCacheResponseOptions,
): Response {
const probeResponse = finalizeProbeAppPageResponse(response, options);
const probeResponse = finalizeEvaluatedAppPageResponse(response, options);
if (probeResponse) {
void options.capturedRscDataPromise?.catch(() => {});
return probeResponse;
Expand Down Expand Up @@ -350,7 +365,7 @@ export function finalizeAppPageRscCacheResponse(
response: Response,
options: ScheduleAppPageRscCacheWriteOptions,
): Response {
const probeResponse = finalizeProbeAppPageResponse(response, options);
const probeResponse = finalizeEvaluatedAppPageResponse(response, options);
if (probeResponse) {
void options.capturedRscDataPromise?.catch(() => {});
return probeResponse;
Expand Down
22 changes: 21 additions & 1 deletion packages/vinext/src/server/app-page-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { resolveClientStaleTimeSeconds } from "../utils/cache-control-metadata.j
import { AppElementsWire, isAppElementsRecord, type AppOutgoingElements } from "./app-elements.js";
import { hasDigest } from "./app-rsc-errors.js";
import {
finalizeAppPageCacheabilityEvaluationResponse,
finalizeAppPageHtmlCacheResponse,
finalizeAppPageRscCacheResponse,
} from "./app-page-cache-finalizer.js";
Expand Down Expand Up @@ -1297,7 +1298,7 @@ export async function renderAppPageLifecycle(
});
}

return buildAppPageHtmlResponse(safeHtmlStream, {
const response = buildAppPageHtmlResponse(safeHtmlStream, {
cacheTags: options.isPrerender === true ? options.getPageTags() : undefined,
draftCookie,
linkHeader,
Expand All @@ -1307,6 +1308,25 @@ export async function renderAppPageLifecycle(
requestCacheLife: requestCacheLifeForPrerender,
timing: htmlResponseTiming,
});
return finalizeAppPageCacheabilityEvaluationResponse(response, {
capturedDynamicUsageBeforeContextCleanup() {
return dynamicUsedBeforeContextCleanup;
},
consumeDynamicUsage: consumeRenderDynamicUsage,
consumeRenderObservationState: options.consumeRenderObservationState,
getPageTags() {
return options.getPageTags();
},
getRequestCacheLife() {
return readRequestCacheLifeForCachePolicy(options);
},
expireSeconds,
revalidateSeconds: resolveAppPageCacheWriteRevalidateSeconds({
isDynamicError: options.isDynamicError,
isForceStatic: options.isForceStatic,
revalidateSeconds,
}),
});
}

async function settleCapturedRscRenderForCacheMetadata(
Expand Down
Loading
Loading