Skip to content

Commit c54f8dc

Browse files
committed
feat(cache): probe Pages Router cacheability
1 parent 9a08dfc commit c54f8dc

19 files changed

Lines changed: 518 additions & 77 deletions

packages/cloudflare/src/cacheability-probe.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ export async function probeStagedWorkerCacheability(options: {
161161
});
162162
if (
163163
result.version !== 1 ||
164-
result.kind !== "app-page" ||
164+
(result.kind !== "app-page" && result.kind !== "pages-page") ||
165165
typeof result.pattern !== "string" ||
166166
!result.pattern.startsWith("/") ||
167167
!isProbeRouteState(result.state) ||
@@ -177,7 +177,7 @@ export async function probeStagedWorkerCacheability(options: {
177177
}
178178

179179
const route: CacheabilityManifestRoute = {
180-
kind: "app-page",
180+
kind: result.kind,
181181
pattern: result.pattern,
182182
representation: identity.representation,
183183
requestKey: identity.requestKey,

packages/cloudflare/src/cdn-warm.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ export type PrerenderWarmPlan = {
8888
buildIdentity?: string;
8989
deploymentId?: string;
9090
loadingShellPaths: string[];
91+
pagesPaths?: string[];
9192
paths: string[];
9293
rscBuildId?: string;
9394
rscPaths: string[];
@@ -238,6 +239,7 @@ export function readPrerenderWarmPlan(
238239
loadingShellPaths: supportsCanonicalRsc
239240
? (manifest.loadingShellPaths ?? []).map(applyConfig)
240241
: [],
242+
...(manifest.pagesPaths ? { pagesPaths: manifest.pagesPaths.map(applyConfig) } : {}),
241243
paths: htmlPaths,
242244
...(supportsCanonicalRsc ? { rscBuildId: manifest.rscBuildId } : {}),
243245
rscPaths: supportsCanonicalRsc ? manifest.rscPaths!.map(applyConfig) : [],

packages/cloudflare/src/deploy.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1096,19 +1096,20 @@ async function deployWithCacheabilityProbe(
10961096
...discovered,
10971097
appPaths: discovered.appPaths ? [...discovered.appPaths] : undefined,
10981098
loadingShellPaths: [...discovered.loadingShellPaths],
1099+
pagesPaths: discovered.pagesPaths ? [...discovered.pagesPaths] : undefined,
10991100
paths: [...discovered.paths],
11001101
rscPaths: [...discovered.rscPaths],
11011102
};
1102-
if (!plan.appPaths) {
1103+
if (!plan.appPaths && !plan.pagesPaths) {
11031104
throw new Error(
1104-
"Two-stage CDN warming requires staged discovery to report App Page route ownership.",
1105+
"Two-stage CDN warming requires staged discovery to report App or Pages route ownership.",
11051106
);
11061107
}
1107-
const appPathSet = new Set(plan.appPaths);
1108-
plan.paths = plan.paths.filter((pathname) => appPathSet.has(pathname));
1108+
const ownedHtmlPaths = new Set([...(plan.appPaths ?? []), ...(plan.pagesPaths ?? [])]);
1109+
plan.paths = plan.paths.filter((pathname) => ownedHtmlPaths.has(pathname));
11091110
if (!hasCdnWarmRequests(plan)) {
11101111
throw new Error(
1111-
"Two-stage CDN warming did not discover any App Page request identities to probe.",
1112+
"Two-stage CDN warming did not discover any page request identities to probe.",
11121113
);
11131114
}
11141115

packages/vinext/src/index.ts

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,7 @@ import { getPagesPreviewModeId } from "./server/pages-preview.js";
288288
import commonjs from "vite-plugin-commonjs";
289289
import { createIgnoreDynamicRequestsPlugin } from "./plugins/ignore-dynamic-requests.js";
290290
import { createTransformCache } from "./plugins/transform-cache.js";
291+
import { isServerEnvironment } from "./plugins/environment.js";
291292
import {
292293
isPathInside,
293294
isPathInsideOrEqual,
@@ -3905,7 +3906,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
39053906
// App Router virtual modules
39063907
if (cleanId === VIRTUAL_RSC_ENTRY) return RESOLVED_RSC_ENTRY;
39073908
if (cleanId === VIRTUAL_CACHEABILITY_MANIFEST) {
3908-
if (this.environment?.name === "rsc" && this.environment.config?.command === "build") {
3909+
const isWorkerBuildEnvironment = hasAppDir
3910+
? this.environment?.name === "rsc"
3911+
: this.environment !== undefined && isServerEnvironment(this.environment);
3912+
if (isWorkerBuildEnvironment && this.environment.config?.command === "build") {
39093913
return { id: `./${CACHEABILITY_MANIFEST_MODULE}`, external: true };
39103914
}
39113915
return RESOLVED_CACHEABILITY_MANIFEST;
@@ -4410,7 +4414,10 @@ export const loadServerActionClient = ${
44104414
apply: "build",
44114415

44124416
generateBundle() {
4413-
if (this.environment?.name !== "rsc") return;
4417+
const isWorkerBuildEnvironment = hasAppDir
4418+
? this.environment?.name === "rsc"
4419+
: this.environment !== undefined && isServerEnvironment(this.environment);
4420+
if (!isWorkerBuildEnvironment) return;
44144421
this.emitFile({
44154422
type: "asset",
44164423
fileName: CACHEABILITY_MANIFEST_MODULE,
@@ -6753,16 +6760,35 @@ export const loadServerActionClient = ${
67536760
sequential: true,
67546761
order: "post" as const,
67556762
handler(options: { dir?: string }) {
6756-
const envName = this.environment?.name;
6757-
// Fire for App Router RSC builds (rsc env) and Pages Router SSR builds
6758-
// (ssr env). Skip client and other environments.
6759-
if (envName !== "rsc" && envName !== "ssr") return;
6763+
const environment = this.environment;
6764+
// App Router metadata belongs to its RSC build. Pages Router may use
6765+
// Vite's `ssr` environment or a platform-owned server environment
6766+
// such as the one created by the Cloudflare Vite plugin.
6767+
if (
6768+
!environment ||
6769+
(hasAppDir ? environment.name !== "rsc" : !isServerEnvironment(environment))
6770+
) {
6771+
return;
6772+
}
67606773

67616774
const outDir = options.dir;
67626775
if (!outDir) return;
67636776

67646777
const manifest = { prerenderSecret };
6765-
fs.writeFileSync(path.join(outDir, "vinext-server.json"), JSON.stringify(manifest));
6778+
const source = JSON.stringify(manifest);
6779+
fs.writeFileSync(path.join(outDir, "vinext-server.json"), source);
6780+
6781+
// Staged discovery and cacheability probing deliberately read build
6782+
// metadata from the platform-independent server directory. A Pages
6783+
// Worker bundle may live in a platform-named output directory, so
6784+
// retain the adjacent copy above and also publish the canonical copy.
6785+
if (!hasAppDir) {
6786+
const canonicalServerDir = path.join(root, "dist", "server");
6787+
if (path.resolve(outDir) !== canonicalServerDir) {
6788+
fs.mkdirSync(canonicalServerDir, { recursive: true });
6789+
fs.writeFileSync(path.join(canonicalServerDir, "vinext-server.json"), source);
6790+
}
6791+
}
67666792
},
67676793
},
67686794
},

packages/vinext/src/server/cacheability-manifest.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL } from "./app-rsc-render-mod
1616

1717
export const CACHEABILITY_MANIFEST_MODULE = "__vinext_cacheability_manifest.js";
1818

19+
export type CacheabilityRouteKind = "app-page" | "pages-page";
1920
export type CacheabilityRepresentation = "html" | "rsc-full" | "rsc-loading-shell";
2021
type CacheabilityManifestRouteState =
2122
| "static-candidate"
@@ -24,7 +25,7 @@ type CacheabilityManifestRouteState =
2425
| "probe-failed";
2526

2627
export type CacheabilityManifestRoute = {
27-
kind: "app-page";
28+
kind: CacheabilityRouteKind;
2829
pattern: string;
2930
representation: CacheabilityRepresentation;
3031
requestKey: string;
@@ -64,7 +65,7 @@ function parseRoute(key: string, value: unknown): CacheabilityManifestRoute | nu
6465
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
6566
const route = value as Record<string, unknown>;
6667
if (
67-
route.kind !== "app-page" ||
68+
(route.kind !== "app-page" && route.kind !== "pages-page") ||
6869
typeof route.pattern !== "string" ||
6970
!route.pattern.startsWith("/") ||
7071
!isRepresentation(route.representation) ||
@@ -78,7 +79,7 @@ function parseRoute(key: string, value: unknown): CacheabilityManifestRoute | nu
7879
return null;
7980
}
8081
const parsed: CacheabilityManifestRoute = {
81-
kind: "app-page",
82+
kind: route.kind,
8283
pattern: route.pattern,
8384
representation: route.representation,
8485
requestKey: route.requestKey,
@@ -175,17 +176,13 @@ export function cacheabilityRequestIdentity(request: Request): {
175176

176177
export function findCacheabilityManifestRoute(
177178
manifest: CacheabilityManifest,
179+
kind: CacheabilityRouteKind,
178180
pattern: string,
179181
identity: { representation: CacheabilityRepresentation; requestKey: string },
180182
): CacheabilityManifestRoute | null {
181183
return (
182184
manifest.routes[
183-
cacheabilityManifestRouteKey(
184-
"app-page",
185-
pattern,
186-
identity.representation,
187-
identity.requestKey,
188-
)
185+
cacheabilityManifestRouteKey(kind, pattern, identity.representation, identity.requestKey)
189186
] ?? null
190187
);
191188
}

packages/vinext/src/server/cacheability-request.ts

Lines changed: 58 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ import {
44
type RouteCacheabilityOutcome,
55
type RouteCacheabilityState,
66
} from "vinext/shims/cacheability-classification";
7-
import { applyCdnResponseHeaders, NO_STORE_CACHE_CONTROL } from "./cache-control.js";
7+
import {
8+
applyCdnResponseHeaders,
9+
hasExplicitNonCacheableResponsePolicy,
10+
NO_STORE_CACHE_CONTROL,
11+
} from "./cache-control.js";
12+
import { isNonCacheableCacheControl } from "vinext/shims/cdn-cache";
813
import { VINEXT_CACHEABILITY_PROBE_HEADER, VINEXT_PRERENDER_SECRET_HEADER } from "./headers.js";
914
import { workerCapabilityMatches } from "./worker-prerender-discovery.js";
1015
import {
@@ -27,7 +32,7 @@ type CacheabilityProbeRouteState =
2732

2833
type CacheabilityProbeResult = {
2934
cacheControl?: string;
30-
kind?: "app-page";
35+
kind?: "app-page" | "pages-page";
3136
pattern?: string;
3237
reason?: string;
3338
state: CacheabilityProbeRouteState;
@@ -285,6 +290,38 @@ function responseWithCachePolicy(
285290
});
286291
}
287292

293+
function inferPagesPageCacheability(response: Response): RouteCacheabilityOutcome {
294+
const cacheControl =
295+
response.headers.get("Cloudflare-CDN-Cache-Control") ??
296+
response.headers.get("CDN-Cache-Control") ??
297+
response.headers.get("Cache-Control");
298+
if (!cacheControl || isNonCacheableCacheControl(cacheControl)) {
299+
return { cacheable: false };
300+
}
301+
const cacheTag = response.headers.get("Cache-Tag");
302+
return {
303+
cacheable: true,
304+
cacheControl,
305+
...(cacheTag
306+
? {
307+
tags: cacheTag
308+
.split(",")
309+
.map((tag) => tag.trim())
310+
.filter(Boolean),
311+
}
312+
: {}),
313+
};
314+
}
315+
316+
function completedRouteOutcome(
317+
response: Response,
318+
state: RouteCacheabilityState,
319+
): RouteCacheabilityOutcome | null {
320+
if (state.route?.kind !== "pages-page") return state.outcome ?? null;
321+
if (hasExplicitNonCacheableResponsePolicy(response.headers)) return { cacheable: false };
322+
return state.outcome ?? inferPagesPageCacheability(response);
323+
}
324+
288325
function staticToDynamicResponse(route: CacheabilityManifestRoute): Response {
289326
const headers = new Headers({ "Content-Type": "text/plain; charset=utf-8" });
290327
applyCdnResponseHeaders(headers, { cacheControl: NO_STORE_CACHE_CONTROL });
@@ -312,12 +349,17 @@ async function finalizeWorkerCacheabilityAdmission(
312349
return responseWithCachePolicy(response, response.body, null);
313350
}
314351
const manifest = admission.manifest as CacheabilityManifest;
315-
const manifestRoute = findCacheabilityManifestRoute(manifest, state.route.pattern, {
316-
representation: admission.representation as Parameters<
317-
typeof findCacheabilityManifestRoute
318-
>[2]["representation"],
319-
requestKey: admission.requestKey,
320-
});
352+
const manifestRoute = findCacheabilityManifestRoute(
353+
manifest,
354+
state.route.kind,
355+
state.route.pattern,
356+
{
357+
representation: admission.representation as Parameters<
358+
typeof findCacheabilityManifestRoute
359+
>[3]["representation"],
360+
requestKey: admission.requestKey,
361+
},
362+
);
321363
if (
322364
!manifestRoute ||
323365
manifestRoute.state === "dynamic" ||
@@ -338,7 +380,9 @@ async function finalizeWorkerCacheabilityAdmission(
338380
return responseWithCachePolicy(response, captured.body, null);
339381
}
340382

341-
const outcome = state.completion ? await state.completion : (state.outcome ?? null);
383+
const outcome = state.completion
384+
? await state.completion
385+
: completedRouteOutcome(response, state);
342386
if (outcome?.cacheable !== true || !outcome.cacheControl) {
343387
return manifestRoute.state === "static-candidate" &&
344388
(outcome === null || outcome.dynamicUsage === true)
@@ -366,7 +410,7 @@ export async function finalizeWorkerCacheabilityResponse(
366410
state.route ? "runtime-check" : "probe-failed",
367411
state.route
368412
? { cacheable: false }
369-
: { cacheable: false, reason: "request did not resolve to a probeable App Page" },
413+
: { cacheable: false, reason: "request did not resolve to a probeable page route" },
370414
response.status,
371415
);
372416
}
@@ -376,7 +420,7 @@ export async function finalizeWorkerCacheabilityResponse(
376420
return probeResponse(
377421
state,
378422
"probe-failed",
379-
{ cacheable: false, reason: "request did not resolve to a probeable App Page" },
423+
{ cacheable: false, reason: "request did not resolve to a probeable page route" },
380424
response.status,
381425
);
382426
}
@@ -401,7 +445,9 @@ export async function finalizeWorkerCacheabilityResponse(
401445
);
402446
}
403447

404-
const outcome = state.completion ? await state.completion : state.outcome;
448+
const outcome = state.completion
449+
? await state.completion
450+
: completedRouteOutcome(response, state);
405451
if (!outcome) {
406452
return probeResponse(
407453
state,

0 commit comments

Comments
 (0)