Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6304,6 +6304,13 @@ export const loadServerActionClient = ${
// compute `'/nodejs'` correctly instead of `''` (issue #1365).
serverDefines["process.env.NEXT_RUNTIME"] = JSON.stringify("nodejs");

// Next evaluates App Page prerenders with NEXT_PHASE set to
// phase-production-build, then evaluates ISR regeneration in the
// production-server phase. A staged probe shares a Worker bundle with
// ordinary requests, so defer this one value to the request-scoped
// server-global accessor instead of baking one phase into the bundle.
serverDefines["process.env.NEXT_PHASE"] = "globalThis.__VINEXT_NEXT_PHASE";

// On-demand ISR revalidation secret — baked SERVER-ONLY (the `client`
// early-return above guarantees it never reaches the browser bundle) so
// every server bundle, and therefore every Workers isolate, shares the
Expand Down
20 changes: 16 additions & 4 deletions packages/vinext/src/server/app-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,15 @@ export type ApplyAppMiddlewareResult =
| {
kind: "continue";
cleanPathname: string;
/** Present on real middleware results; optional for older generated callers. */
matched?: boolean;
rewritten: boolean;
search: string | null;
}
| {
kind: "response";
/** Present on real middleware results; optional for older generated callers. */
matched?: boolean;
response: Response;
};

Expand Down Expand Up @@ -270,6 +274,7 @@ export async function applyAppMiddleware(
options: ApplyAppMiddlewareOptions,
): Promise<ApplyAppMiddlewareResult> {
const forwarded = applyForwardedMiddlewareContext(options.request, options.context);
let matched = forwarded.applied;
let cleanPathname = options.cleanPathname;
let rewritten = false;
let search: string | null = null;
Expand All @@ -282,13 +287,15 @@ export async function applyAppMiddleware(
if (options.middlewareRequest) cancelRequestBody(options.middlewareRequest);
return {
kind: "response",
matched,
response: validationResponseWithMiddlewareHeaders(validationResponse, options.context),
};
}
if (options.middlewareRequest) cancelRequestBody(options.middlewareRequest);
const externalRequest = options.externalRewriteRequest ?? options.request;
return {
kind: "response",
matched,
response: await proxyExternalMiddlewareRewrite(
externalRequest,
forwarded.rewriteUrl,
Expand Down Expand Up @@ -325,6 +332,9 @@ export async function applyAppMiddleware(
isProxy: options.isProxy,
module: options.module,
normalizedPathname: cleanPathname,
onMatch() {
matched = true;
},
requestBodyAlreadyIsolated: true,
request: middlewareRequest,
trailingSlash: options.trailingSlash,
Expand All @@ -339,12 +349,12 @@ export async function applyAppMiddleware(
if (!result.continue) {
cancelRequestBody(options.request);
if (result.redirectUrl) {
return { kind: "response", response: responseFromMiddlewareRedirect(result) };
return { kind: "response", matched, response: responseFromMiddlewareRedirect(result) };
}
if (result.response) {
return { kind: "response", response: result.response };
return { kind: "response", matched, response: result.response };
}
return { kind: "response", response: internalServerErrorResponse() };
return { kind: "response", matched, response: internalServerErrorResponse() };
}

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

return { kind: "continue", cleanPathname, rewritten, search };
return { kind: "continue", cleanPathname, matched, rewritten, search };
}
95 changes: 94 additions & 1 deletion packages/vinext/src/server/app-page-cache-finalizer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import type { AppRscRenderMode } from "./app-rsc-render-mode.js";
import { applyCdnResponseHeaders, NO_STORE_CACHE_CONTROL } from "./cache-control.js";
import {
applyCdnResponseHeaders,
buildRevalidateCacheControl,
hasExplicitNonCacheableResponsePolicy,
NO_STORE_CACHE_CONTROL,
STATIC_CACHE_CONTROL,
} from "./cache-control.js";
import { setCacheStateHeaders } from "./cache-headers.js";
import { NEXTJS_CACHE_HEADER, VINEXT_CACHE_HEADER } from "./headers.js";
import {
Expand All @@ -12,6 +18,12 @@ import type { RenderObservation } from "./cache-proof.js";
import { resolveClientStaleTimeSeconds } from "../utils/cache-control-metadata.js";
import { readStreamAsText } from "../utils/text-stream.js";
import { markFrameworkLinkHeaders } from "./app-response-header-provenance.js";
import { deferUntilStreamConsumed } from "./defer-until-stream-consumed.js";
import {
deferRouteCacheability,
isRouteCacheabilityProbe,
type RouteCacheabilityOutcome,
} from "vinext/shims/cacheability-classification";

type AppPageDebugLogger = (event: string, detail: string) => void;
type AppPageRscCacheKeyBuilder = (
Expand Down Expand Up @@ -157,10 +169,86 @@ function resolveAppPageCacheControl(options: {
});
}

function appPageCacheControlHeader(cacheControl: CacheControlMetadata): string {
return cacheControl.revalidate === Infinity
? STATIC_CACHE_CONTROL
: buildRevalidateCacheControl(cacheControl.revalidate, cacheControl.expire);
}

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

let completed = false;
const finish = (): void => {
if (completed) return;
completed = true;

let outcome: RouteCacheabilityOutcome;
if (
options.capturedDynamicUsageBeforeContextCleanup?.() === true ||
options.consumeDynamicUsage()
) {
outcome = {
cacheable: false,
dynamicUsage: true,
reason: "dynamic API used during render",
};
} else if (
response.headers.has("set-cookie") ||
hasExplicitNonCacheableResponsePolicy(response.headers)
) {
outcome = { cacheable: false, reason: "response explicitly opts out of shared caching" };
} else {
const cacheControl = resolveAppPageCacheControl({
expireSeconds: options.expireSeconds,
requestCacheLife: options.getRequestCacheLife?.(),
revalidateSeconds: options.revalidateSeconds,
});
outcome = cacheControl
? {
cacheable: true,
cacheControl: appPageCacheControlHeader(cacheControl),
tags: options.getPageTags(),
}
: { cacheable: false, reason: "render did not produce a cache policy" };
}
options.consumeRenderObservationState?.();
complete(outcome);
};

if (!response.body) {
finish();
return response;
}
return new Response(deferUntilStreamConsumed(response.body, finish), {
headers: response.headers,
status: response.status,
statusText: response.statusText,
});
}

export function finalizeAppPageHtmlCacheResponse(
response: Response,
options: FinalizeAppPageHtmlCacheResponseOptions,
): Response {
const probeResponse = finalizeProbeAppPageResponse(response, options);
if (probeResponse) {
void options.capturedRscDataPromise?.catch(() => {});
return probeResponse;
}
if (!response.body) {
return response;
}
Expand Down Expand Up @@ -262,6 +350,11 @@ export function finalizeAppPageRscCacheResponse(
response: Response,
options: ScheduleAppPageRscCacheWriteOptions,
): Response {
const probeResponse = finalizeProbeAppPageResponse(response, options);
if (probeResponse) {
void options.capturedRscDataPromise?.catch(() => {});
return probeResponse;
}
// Persisting to the ISR store and finalizing the client-facing headers are
// independent decisions. Mounted-slot and unverified-interception variants
// are deliberately never stored, but a fresh MISS stream can still reach a
Expand Down
11 changes: 11 additions & 0 deletions packages/vinext/src/server/app-page-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ import {
isAppLayoutObservationUnsafeForStaticReuse,
type AppLayoutParamAccessTracker,
} from "./app-layout-param-observation.js";
import {
beginRouteCacheability,
isRouteCacheabilityIdentityProbe,
isRouteCacheabilityProbe,
} from "vinext/shims/cacheability-classification";

type AppPageParams = Record<string, string | string[]>;
type AppPageElement = ReactNode | Readonly<Record<string, ReactNode>>;
Expand Down Expand Up @@ -632,6 +637,11 @@ async function dispatchAppPageInner<TRoute extends AppPageDispatchRoute>(
options: DispatchAppPageOptions<TRoute>,
): Promise<Response> {
const route = options.route;
beginRouteCacheability("app-page", route.pattern);
if (isRouteCacheabilityIdentityProbe()) {
options.clearRequestContext();
return new Response(null, { status: 204 });
}
const dynamicConfig = options.dynamicConfig;
const currentRevalidateSeconds = options.revalidateSeconds;
const interceptionId = options.isRscRequest
Expand Down Expand Up @@ -711,6 +721,7 @@ async function dispatchAppPageInner<TRoute extends AppPageDispatchRoute>(
}

if (
!isRouteCacheabilityProbe() &&
options.bypassInterceptionContextCache !== true &&
shouldReadAppPageCache({
isDraftMode,
Expand Down
29 changes: 25 additions & 4 deletions packages/vinext/src/server/app-router-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
isOpenRedirectShaped,
} from "./request-pipeline.js";
import {
VINEXT_CACHEABILITY_PROBE_HEADER,
VINEXT_PRERENDER_ROUTE_PARAMS_HEADER,
VINEXT_PRERENDER_SECRET_HEADER,
VINEXT_REVALIDATE_HOST_HEADER,
Expand Down Expand Up @@ -107,7 +108,24 @@ async function handleRequest(
: createWorkerRevalidationContext(platformCtx, (internalRequest, internalCtx) =>
handleRequest(internalRequest, env, internalCtx),
);
const ctx = createWorkerPrerenderDiscoveryContext(requestCtx, request, __rscPrerenderSecret);
let ctx = createWorkerPrerenderDiscoveryContext(requestCtx, request, __rscPrerenderSecret);
let finalizeCacheabilityResponse:
| ((response: Response, ctx: ExecutionContextLike) => Promise<Response>)
| undefined;
if (request.headers.has(VINEXT_CACHEABILITY_PROBE_HEADER)) {
// Keep the capture/classification runtime out of every ordinary request.
// Authentication still happens before internal headers are removed below.
const cacheability = await import("./cacheability-request.js");
const probeContext = cacheability.createWorkerCacheabilityContext(
ctx,
request,
__rscPrerenderSecret,
);
if (probeContext !== ctx) {
ctx = probeContext;
finalizeCacheabilityResponse = cacheability.finalizeWorkerCacheabilityResponse;
}
}

// Register config-driven cache adapters before any rendering touches the cache.
registerConfiguredCacheAdapters(env as Record<string, unknown> | undefined);
Expand Down Expand Up @@ -204,12 +222,15 @@ async function handleRequest(
});
if (assetResponse) response = assetResponse;
}
return finalizeMissingStaticAssetResponse(response, missingBuildAsset);
response = finalizeMissingStaticAssetResponse(response, missingBuildAsset);
return finalizeCacheabilityResponse ? finalizeCacheabilityResponse(response, ctx) : response;
}

if (result === null || result === undefined) {
return missingBuildAsset ? notFoundStaticAssetResponse() : notFoundResponse();
const response = missingBuildAsset ? notFoundStaticAssetResponse() : notFoundResponse();
return finalizeCacheabilityResponse ? finalizeCacheabilityResponse(response, ctx) : response;
}

return new Response(String(result), { status: 200 });
const response = new Response(String(result), { status: 200 });
return finalizeCacheabilityResponse ? finalizeCacheabilityResponse(response, ctx) : response;
}
11 changes: 11 additions & 0 deletions packages/vinext/src/server/app-rsc-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ import {
type AppRouteTreePrefetchRoute,
type PrefetchInliningConfig,
} from "./app-route-tree-prefetch.js";
import { markRouteCacheabilityDynamic } from "vinext/shims/cacheability-classification";

type AppPageParams = Record<string, string | string[]>;
type RequestContext = ReturnType<typeof requestContextFromRequest>;
Expand Down Expand Up @@ -898,6 +899,13 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
request: userlandRequest,
validateExternalRewriteRequest: () => validateClaimedOutsideBasePathRsc(true),
});
if (middlewareResult.matched) {
// Next.js runs matched middleware before serving a page response. A CDN
// HIT in front of this Worker would skip that request-specific boundary,
// so this architecture must remain private until middleware is isolated
// into an uncached outer stage.
markRouteCacheabilityDynamic("middleware matched this request");
}
if (middlewareResult.kind === "response") {
if (request.body && !request.body.locked) {
void request.body.cancel().catch(() => {});
Expand Down Expand Up @@ -1251,6 +1259,9 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
void sourceRequest.body.cancel().catch(() => {});
}
}
if (sourceMiddlewareResult.matched) {
markRouteCacheabilityDynamic("middleware matched this request");
}
if (sourceMiddlewareResult.kind === "response") {
options.clearRequestContext();
return sourceMiddlewareResult.response;
Expand Down
5 changes: 5 additions & 0 deletions packages/vinext/src/server/cacheability-limits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/** Maximum response body that an authenticated cacheability probe will drain. */
export const CACHEABILITY_PROBE_BODY_LIMIT = 4 * 1024 * 1024;

/** Leave headroom below the deploy-side request timeout for a fail-closed envelope. */
export const CACHEABILITY_PROBE_TIMEOUT_MS = 20_000;
Loading
Loading