@vinext/cloudflare 1.0.0-beta.5 → 1.0.0-beta.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cdn-warm.js CHANGED
@@ -1,9 +1,26 @@
1
- import path from "node:path";
1
+ import { VINEXT_CDN_BUILD_ID_HEADER } from "./cache/cdn-build-id.js";
2
2
  import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
6
+ import { VINEXT_PRERENDER_READINESS_HEADER, VINEXT_PRERENDER_READINESS_PATH, VINEXT_PRERENDER_SECRET_HEADER } from "vinext/internal/server/headers";
3
7
  import { PRERENDER_PATHS_MANIFEST } from "vinext/internal/build/prerender-paths";
4
8
  import { getPrerenderedConcretePaths, readPrerenderManifest } from "vinext/internal/server/prerender-manifest";
9
+ import { VINEXT_RSC_BUILD_ID_HEADER, VINEXT_RSC_CONTENT_TYPE, VINEXT_RSC_VARY_HEADER, createCanonicalLoadingShellRscRequestHeaders, createCanonicalRscRequestHeaders, createCanonicalRscRequestUrl, createRscRequestUrl } from "vinext/internal/server/app-rsc-cache-busting";
10
+ import { isNonCacheableCacheControl } from "vinext/shims/cdn-cache";
11
+ import { normalizePathTrailingSlash } from "vinext/shims/url-utils";
5
12
  //#region src/cdn-warm.ts
6
- const DEFAULT_CDN_WARM_TIMEOUT_MS = 5e3;
13
+ const DEFAULT_CDN_WARM_CONCURRENCY = 25;
14
+ const DEFAULT_CDN_WARM_TIMEOUT_MS = 1e4;
15
+ const DEFAULT_STAGED_READINESS_RETRIES = 60;
16
+ const DEFAULT_STAGED_READINESS_INTERVAL_MS = 1e3;
17
+ const DEFAULT_STAGED_READINESS_PHASE_TIMEOUT_MS = 12e4;
18
+ const DEFAULT_STAGED_READINESS_SUCCESSES = 6;
19
+ const STAGED_READINESS_QUERY_PARAM = "__vinext_cdn_warm_readiness";
20
+ function applyWarmPathConfig(pathname, config) {
21
+ const normalized = normalizePathTrailingSlash(config.basePath ? pathname === "/" ? config.basePath : `${config.basePath}${pathname}` : pathname, config.trailingSlash === true);
22
+ return new URL(normalized, "http://vinext.local").pathname;
23
+ }
7
24
  function readBuiltBuildId(root) {
8
25
  try {
9
26
  const buildId = fs.readFileSync(path.join(root, "dist", "server", "BUILD_ID"), "utf-8").trim();
@@ -19,7 +36,7 @@ function readPrerenderPathManifest(manifestPath) {
19
36
  const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
20
37
  if (!parsed || typeof parsed !== "object") return null;
21
38
  const manifest = parsed;
22
- if (!Array.isArray(manifest.paths)) return null;
39
+ if (!Array.isArray(manifest.paths) || !manifest.paths.every((pathname) => typeof pathname === "string") || manifest.appPaths !== void 0 && (!Array.isArray(manifest.appPaths) || !manifest.appPaths.every((pathname) => typeof pathname === "string")) || manifest.pagesPaths !== void 0 && (!Array.isArray(manifest.pagesPaths) || !manifest.pagesPaths.every((pathname) => typeof pathname === "string")) || manifest.pagesDataPaths !== void 0 && (!Array.isArray(manifest.pagesDataPaths) || !manifest.pagesDataPaths.every((pathname) => typeof pathname === "string")) || manifest.excludedWarmPaths !== void 0 && (!Array.isArray(manifest.excludedWarmPaths) || !manifest.excludedWarmPaths.every((pathname) => typeof pathname === "string")) || manifest.fallbackRoutePatterns !== void 0 && (!Array.isArray(manifest.fallbackRoutePatterns) || !manifest.fallbackRoutePatterns.every((route) => route !== null && typeof route === "object" && !Array.isArray(route) && (route.kind === "app-page" || route.kind === "app-route" || route.kind === "pages-page") && typeof route.pattern === "string" && route.pattern.startsWith("/"))) || manifest.rscPaths !== void 0 && (!Array.isArray(manifest.rscPaths) || !manifest.rscPaths.every((pathname) => typeof pathname === "string")) || manifest.routeHandlerPaths !== void 0 && (!Array.isArray(manifest.routeHandlerPaths) || !manifest.routeHandlerPaths.every((pathname) => typeof pathname === "string")) || manifest.routePatterns !== void 0 && (!manifest.routePatterns || typeof manifest.routePatterns !== "object" || Array.isArray(manifest.routePatterns) || !Object.entries(manifest.routePatterns).every(([pathname, route]) => pathname.startsWith("/") && route !== null && typeof route === "object" && !Array.isArray(route) && (route.kind === "app-page" || route.kind === "app-route" || route.kind === "pages-page") && typeof route.pattern === "string" && route.pattern.startsWith("/") && (route.cacheabilityProbe === void 0 || route.cacheabilityProbe !== null && typeof route.cacheabilityProbe === "object" && !Array.isArray(route.cacheabilityProbe) && typeof route.cacheabilityProbe.canPrunePattern === "boolean" && (route.cacheabilityProbe.concretePathname === void 0 || typeof route.cacheabilityProbe.concretePathname === "string" && route.cacheabilityProbe.concretePathname.startsWith("/"))))) || manifest.loadingShellPaths !== void 0 && (!Array.isArray(manifest.loadingShellPaths) || !manifest.loadingShellPaths.every((pathname) => typeof pathname === "string")) || manifest.basePath !== void 0 && typeof manifest.basePath !== "string" || manifest.buildIdentity !== void 0 && typeof manifest.buildIdentity !== "string" || manifest.deploymentId !== void 0 && typeof manifest.deploymentId !== "string" || manifest.rscBuildId !== void 0 && typeof manifest.rscBuildId !== "string" || manifest.trailingSlash !== void 0 && typeof manifest.trailingSlash !== "boolean" || manifest.responseVary !== void 0 && manifest.responseVary !== "verbatim") return null;
23
40
  return manifest;
24
41
  } catch (error) {
25
42
  console.warn(`[vinext] Failed to read prerender path manifest at ${manifestPath}:`, error);
@@ -34,37 +51,58 @@ function readPrerenderPathWarmPaths(root, options) {
34
51
  const message = "[vinext] CDN warmup skipped: prerender path manifest buildId does not match dist/server/BUILD_ID.";
35
52
  if (options?.strict) throw new Error(message);
36
53
  console.warn(message);
37
- return [];
54
+ return null;
38
55
  }
39
- return manifest.paths.filter((pathname) => pathname.startsWith("/"));
56
+ return {
57
+ manifest,
58
+ pagesPaths: (manifest.pagesPaths ?? []).filter((pathname) => pathname.startsWith("/")).filter((pathname) => !manifest.excludedWarmPaths?.includes(pathname)).map((pathname) => applyWarmPathConfig(pathname, manifest)),
59
+ paths: manifest.paths.filter((pathname) => pathname.startsWith("/")).filter((pathname) => !manifest.excludedWarmPaths?.includes(pathname)).map((pathname) => applyWarmPathConfig(pathname, manifest))
60
+ };
40
61
  }
41
- function readPrerenderWarmPaths(root, options) {
42
- const shouldPreferPrerenderManifest = options?.includeFallbackShells === true;
43
- if (!shouldPreferPrerenderManifest) {
44
- const pathManifestPaths = readPrerenderPathWarmPaths(root, options);
45
- if (pathManifestPaths !== null) return pathManifestPaths;
46
- }
47
- const manifest = readPrerenderManifest(path.join(root, "dist", "server", "vinext-prerender.json"));
48
- if (!manifest) {
49
- if (shouldPreferPrerenderManifest) {
50
- const pathManifestPaths = readPrerenderPathWarmPaths(root, options);
51
- if (pathManifestPaths !== null) {
52
- console.warn("[vinext] CDN warmup fallback shells requested, but prerender manifest not found; warming build-discovered paths only.");
53
- return pathManifestPaths;
54
- }
55
- }
56
- const message = "[vinext] CDN warmup skipped: prerender manifest not found.";
62
+ function readPrerenderWarmPlan(root, options) {
63
+ const pathPlan = readPrerenderPathWarmPaths(root, options);
64
+ if (!pathPlan) {
65
+ const message = "[vinext] CDN warmup skipped: prerender path manifest not found.";
57
66
  if (options?.strict) throw new Error(message);
58
- return [];
67
+ return {
68
+ loadingShellPaths: [],
69
+ paths: [],
70
+ rscPaths: []
71
+ };
59
72
  }
60
- const builtBuildId = readBuiltBuildId(root);
61
- if (!manifest.buildId || !builtBuildId || manifest.buildId !== builtBuildId) {
62
- const message = "[vinext] CDN warmup skipped: prerender manifest buildId does not match dist/server/BUILD_ID.";
63
- if (options?.strict) throw new Error(message);
64
- console.warn(message);
65
- return [];
73
+ const { manifest } = pathPlan;
74
+ const supportsCanonicalRsc = manifest.responseVary === "verbatim" && manifest.rscPaths !== void 0 && manifest.rscBuildId !== void 0;
75
+ const applyConfig = (pathname) => applyWarmPathConfig(pathname, manifest);
76
+ const routePatterns = manifest.routePatterns ? Object.fromEntries(Object.entries(manifest.routePatterns).map(([pathname, route]) => [pathname.includes("/_next/data/") ? pathname : applyConfig(pathname), route])) : void 0;
77
+ let htmlPaths = pathPlan.paths;
78
+ if (options?.includeFallbackShells === true) {
79
+ const prerenderManifest = readPrerenderManifest(path.join(root, "dist", "server", "vinext-prerender.json"));
80
+ if (!prerenderManifest) console.warn("[vinext] CDN warmup fallback shells requested, but prerender manifest not found; warming build-discovered paths only.");
81
+ else if (!prerenderManifest.buildId || prerenderManifest.buildId !== manifest.buildId) {
82
+ const message = "[vinext] CDN warmup skipped: prerender manifest buildId does not match dist/server/BUILD_ID.";
83
+ if (options.strict) throw new Error(message);
84
+ console.warn(message);
85
+ htmlPaths = [];
86
+ } else htmlPaths = getPrerenderedConcretePaths(prerenderManifest, { includeFallbackShells: true }).filter((pathname) => pathname.startsWith("/")).filter((pathname) => !manifest.excludedWarmPaths?.includes(pathname)).map(applyConfig);
66
87
  }
67
- return getPrerenderedConcretePaths(manifest, options);
88
+ return {
89
+ ...manifest.appPaths ? { appPaths: manifest.appPaths.map(applyConfig) } : {},
90
+ buildId: manifest.buildId,
91
+ ...manifest.buildIdentity ? { buildIdentity: manifest.buildIdentity } : {},
92
+ ...manifest.deploymentId ? { deploymentId: manifest.deploymentId } : {},
93
+ ...manifest.fallbackRoutePatterns ? { fallbackRoutePatterns: manifest.fallbackRoutePatterns } : {},
94
+ loadingShellPaths: supportsCanonicalRsc ? (manifest.loadingShellPaths ?? []).map(applyConfig) : [],
95
+ ...manifest.pagesDataPaths ? { pagesDataPaths: manifest.pagesDataPaths } : {},
96
+ ...manifest.pagesPaths ? { pagesPaths: manifest.pagesPaths.map(applyConfig) } : {},
97
+ paths: htmlPaths,
98
+ ...supportsCanonicalRsc ? { rscBuildId: manifest.rscBuildId } : {},
99
+ rscPaths: supportsCanonicalRsc ? manifest.rscPaths.map(applyConfig) : [],
100
+ ...manifest.routeHandlerPaths ? { routeHandlerPaths: manifest.routeHandlerPaths.map(applyConfig) } : {},
101
+ ...routePatterns ? { routePatterns } : {}
102
+ };
103
+ }
104
+ function readPrerenderWarmPaths(root, options) {
105
+ return readPrerenderWarmPlan(root, options).paths;
68
106
  }
69
107
  function getWarmPathsFromPrerenderManifest(manifest, options) {
70
108
  return getPrerenderedConcretePaths(manifest, options);
@@ -75,45 +113,518 @@ function normalizeWarmPath(pathname) {
75
113
  function buildWarmupUrl(targetUrl, pathname) {
76
114
  return new URL(normalizeWarmPath(pathname), targetUrl.endsWith("/") ? targetUrl : `${targetUrl}/`);
77
115
  }
78
- function isRetryableStatus(status) {
79
- return status === 408 || status === 429 || status >= 500;
116
+ function isRetryableStatus(status, retryNotFound) {
117
+ return status === 408 || status === 429 || status >= 500 || retryNotFound && status === 404;
118
+ }
119
+ const WORKER_VERSION_OVERRIDE_HEADER = "Cloudflare-Workers-Version-Overrides";
120
+ async function fetchWithTimeout(fetchImpl, url, timeoutMs, headers, redirect) {
121
+ const controller = new AbortController();
122
+ let response;
123
+ let timer;
124
+ const requestHeaders = new Headers(headers);
125
+ requestHeaders.set("User-Agent", "vinext-cloudflare-cdn-warm");
126
+ try {
127
+ const timedOut = new Promise((_resolve, reject) => {
128
+ timer = setTimeout(() => {
129
+ controller.abort();
130
+ reject(new DOMException(`Timed out after ${timeoutMs}ms`, "AbortError"));
131
+ }, timeoutMs);
132
+ });
133
+ return await Promise.race([(async () => {
134
+ response = await fetchImpl(url, {
135
+ method: "GET",
136
+ redirect,
137
+ headers: requestHeaders,
138
+ signal: controller.signal
139
+ });
140
+ return {
141
+ body: await response.arrayBuffer(),
142
+ response
143
+ };
144
+ })(), timedOut]);
145
+ } finally {
146
+ if (timer !== void 0) clearTimeout(timer);
147
+ if (controller.signal.aborted && response?.body) response.body.cancel().catch(() => {});
148
+ }
80
149
  }
81
- async function fetchWithTimeout(fetchImpl, url, timeoutMs, headers) {
150
+ async function fetchHeadersWithTimeout(fetchImpl, url, timeoutMs, headers, method = "GET") {
82
151
  const controller = new AbortController();
83
- const timer = setTimeout(() => controller.abort(), timeoutMs);
84
152
  const requestHeaders = new Headers(headers);
85
153
  requestHeaders.set("User-Agent", "vinext-cloudflare-cdn-warm");
154
+ let timer;
86
155
  try {
87
- return await fetchImpl(url, {
88
- method: "GET",
89
- redirect: "follow",
156
+ const timedOut = new Promise((_resolve, reject) => {
157
+ timer = setTimeout(() => {
158
+ controller.abort();
159
+ reject(new DOMException(`Timed out after ${timeoutMs}ms`, "AbortError"));
160
+ }, timeoutMs);
161
+ });
162
+ return await Promise.race([fetchImpl(url, {
163
+ method,
164
+ redirect: "manual",
90
165
  headers: requestHeaders,
91
166
  signal: controller.signal
92
- });
167
+ }), timedOut]);
93
168
  } finally {
94
- clearTimeout(timer);
95
- }
96
- }
97
- async function warmOnePath(pathname, options) {
98
- const url = buildWarmupUrl(options.targetUrl, pathname);
99
- let lastError = "unknown error";
100
- for (let attempt = 0; attempt <= options.retries; attempt++) try {
101
- const response = await fetchWithTimeout(options.fetchImpl, url, options.timeoutMs, options.headers);
102
- await response.arrayBuffer();
103
- if (response.status < 400) return {
104
- path: pathname,
105
- ok: true
169
+ if (timer !== void 0) clearTimeout(timer);
170
+ }
171
+ }
172
+ async function createCdnWarmTargets(options) {
173
+ const requests = [];
174
+ const fullRscPaths = new Set(options.rscPaths ?? []);
175
+ const loadingShellPaths = new Set(options.loadingShellPaths ?? []);
176
+ const commonHeaders = new Headers(options.headers);
177
+ for (const pathname of /* @__PURE__ */ new Set([...fullRscPaths, ...loadingShellPaths])) {
178
+ if (fullRscPaths.has(pathname)) {
179
+ const rscHeaders = new Headers(commonHeaders);
180
+ for (const [name, value] of createCanonicalRscRequestHeaders(options.deploymentId)) rscHeaders.set(name, value);
181
+ requests.push({
182
+ headers: rscHeaders,
183
+ kind: "rsc-full",
184
+ label: `${pathname} (RSC full)`,
185
+ pathname: createCanonicalRscRequestUrl(pathname),
186
+ sourcePathname: pathname,
187
+ route: options.routePatterns?.[pathname]
188
+ });
189
+ }
190
+ if (loadingShellPaths.has(pathname)) {
191
+ const loadingHeaders = new Headers(commonHeaders);
192
+ for (const [name, value] of createCanonicalLoadingShellRscRequestHeaders(options.deploymentId)) loadingHeaders.set(name, value);
193
+ requests.push({
194
+ headers: loadingHeaders,
195
+ kind: "rsc-loading-shell",
196
+ label: `${pathname} (RSC loading shell)`,
197
+ pathname: await createRscRequestUrl(pathname, loadingHeaders),
198
+ sourcePathname: pathname,
199
+ route: options.routePatterns?.[pathname]
200
+ });
201
+ }
202
+ }
203
+ for (const pathname of new Set(options.paths)) {
204
+ const htmlHeaders = new Headers(commonHeaders);
205
+ htmlHeaders.set("Accept", "text/html");
206
+ requests.push({
207
+ headers: htmlHeaders,
208
+ kind: "html",
209
+ label: pathname,
210
+ pathname,
211
+ sourcePathname: pathname,
212
+ route: options.routePatterns?.[pathname]
213
+ });
214
+ }
215
+ for (const pathname of new Set(options.pagesDataPaths ?? [])) {
216
+ const dataHeaders = new Headers(commonHeaders);
217
+ dataHeaders.set("Accept", "application/json");
218
+ requests.push({
219
+ headers: dataHeaders,
220
+ kind: "pages-data",
221
+ label: `${pathname} (Pages data)`,
222
+ pathname,
223
+ sourcePathname: pathname,
224
+ route: options.routePatterns?.[pathname]
225
+ });
226
+ }
227
+ for (const pathname of new Set(options.routeHandlerPaths ?? [])) {
228
+ const routeHeaders = new Headers(commonHeaders);
229
+ routeHeaders.set("Accept", "*/*");
230
+ requests.push({
231
+ headers: routeHeaders,
232
+ kind: "app-route",
233
+ label: `${pathname} (Route Handler)`,
234
+ pathname,
235
+ sourcePathname: pathname,
236
+ route: options.routePatterns?.[pathname]
237
+ });
238
+ }
239
+ return requests;
240
+ }
241
+ var CdnOperationProgress = class {
242
+ isTTY = process.stderr.isTTY;
243
+ lastLineLength = 0;
244
+ update(completed, total, label, phase = "Warming CDN cache") {
245
+ if (!this.isTTY) return;
246
+ const percent = total > 0 ? Math.floor(completed / total * 100) : 0;
247
+ const filled = Math.floor(percent / 5);
248
+ const bar = `[${"█".repeat(filled)}${" ".repeat(20 - filled)}]`;
249
+ const shortLabel = label.length > 40 ? `…${label.slice(-39)}` : label;
250
+ const line = `${phase}... ${bar} ${String(completed).padStart(String(total).length)}/${total} ${shortLabel}`;
251
+ process.stderr.write(`\r${line.padEnd(this.lastLineLength)}`);
252
+ this.lastLineLength = line.length;
253
+ }
254
+ finish() {
255
+ if (!this.isTTY || this.lastLineLength === 0) return;
256
+ process.stderr.write(`\r${" ".repeat(this.lastLineLength)}\r`);
257
+ this.lastLineLength = 0;
258
+ }
259
+ };
260
+ const REQUIRED_RSC_VARY_HEADERS = VINEXT_RSC_VARY_HEADER.split(",").map((name) => name.trim().toLowerCase());
261
+ const ADMITTED_CF_CACHE_STATUSES = /* @__PURE__ */ new Set([
262
+ "HIT",
263
+ "MISS",
264
+ "EXPIRED",
265
+ "REVALIDATED",
266
+ "UPDATING"
267
+ ]);
268
+ const REUSABLE_CF_CACHE_STATUSES = /* @__PURE__ */ new Set([
269
+ "HIT",
270
+ "REVALIDATED",
271
+ "UPDATING"
272
+ ]);
273
+ const NON_CACHEABLE_CF_CACHE_STATUSES = /* @__PURE__ */ new Set(["BYPASS"]);
274
+ const CDN_CACHE_POLICY_HEADERS = [
275
+ "Cloudflare-CDN-Cache-Control",
276
+ "CDN-Cache-Control",
277
+ "Cache-Control"
278
+ ];
279
+ function splitCacheControlDirectives(value) {
280
+ const directives = [];
281
+ let start = 0;
282
+ let quoted = false;
283
+ let escaped = false;
284
+ for (let index = 0; index < value.length; index++) {
285
+ const character = value[index];
286
+ if (escaped) escaped = false;
287
+ else if (quoted && character === "\\") escaped = true;
288
+ else if (character === "\"") quoted = !quoted;
289
+ else if (character === "," && !quoted) {
290
+ directives.push(value.slice(start, index));
291
+ start = index + 1;
292
+ }
293
+ }
294
+ directives.push(value.slice(start));
295
+ return directives;
296
+ }
297
+ function hasFieldQualifiedSetCookie(value) {
298
+ return splitCacheControlDirectives(value).some((directive) => {
299
+ const separator = directive.indexOf("=");
300
+ if (separator === -1) return false;
301
+ const name = directive.slice(0, separator).trim().toLowerCase();
302
+ if (name !== "private" && name !== "no-cache") return false;
303
+ const rawFields = directive.slice(separator + 1).trim();
304
+ return (rawFields.startsWith("\"") && rawFields.endsWith("\"") ? rawFields.slice(1, -1) : rawFields).split(",").some((field) => field.trim().toLowerCase() === "set-cookie");
305
+ });
306
+ }
307
+ function validateCachePolicy(response, requireCacheStatus, requireCacheHit = false) {
308
+ const effectivePolicy = CDN_CACHE_POLICY_HEADERS.map((name) => ({
309
+ name,
310
+ value: response.headers.get(name)
311
+ })).find((entry) => entry.value !== null);
312
+ const nonCacheableHeaders = effectivePolicy && isNonCacheableCacheControl(effectivePolicy.value) ? [effectivePolicy.name] : [];
313
+ const hasSetCookie = response.headers.has("Set-Cookie");
314
+ const cacheStatus = response.headers.get("CF-Cache-Status")?.trim().toUpperCase();
315
+ if (requireCacheHit && !REUSABLE_CF_CACHE_STATUSES.has(cacheStatus ?? "")) return {
316
+ outcome: "failed",
317
+ error: `CF-Cache-Status is ${cacheStatus ?? "missing"}; the cache fill is not reusable`
318
+ };
319
+ if (cacheStatus && ADMITTED_CF_CACHE_STATUSES.has(cacheStatus)) {
320
+ if (hasSetCookie && (!effectivePolicy || !hasFieldQualifiedSetCookie(effectivePolicy.value))) return {
321
+ outcome: "failed",
322
+ error: "response sets a cookie without an observable field-qualified cache policy"
323
+ };
324
+ return { outcome: "warmed" };
325
+ }
326
+ if (cacheStatus && NON_CACHEABLE_CF_CACHE_STATUSES.has(cacheStatus)) return {
327
+ outcome: "skipped",
328
+ reason: nonCacheableHeaders.length > 0 ? `${nonCacheableHeaders.join(", ")} opts out of caching` : hasSetCookie ? "response sets a cookie" : `CF-Cache-Status is ${cacheStatus}`
329
+ };
330
+ if (cacheStatus === "DYNAMIC") {
331
+ if (nonCacheableHeaders.length > 0 || hasSetCookie) return {
332
+ outcome: "skipped",
333
+ reason: nonCacheableHeaders.length > 0 ? `${nonCacheableHeaders.join(", ")} opts out of caching` : "response sets a cookie"
334
+ };
335
+ return {
336
+ outcome: "failed",
337
+ error: "CF-Cache-Status is DYNAMIC"
106
338
  };
107
- lastError = `HTTP ${response.status}`;
108
- if (!isRetryableStatus(response.status)) break;
109
- } catch (error) {
110
- if (error instanceof DOMException && error.name === "AbortError") lastError = `timed out after ${options.timeoutMs}ms`;
111
- else lastError = error instanceof Error ? error.message : String(error);
112
339
  }
340
+ if (nonCacheableHeaders.length > 0 || hasSetCookie) return {
341
+ outcome: "failed",
342
+ error: `${nonCacheableHeaders.length > 0 ? `${nonCacheableHeaders.join(", ")} opts out of caching` : "response sets a cookie"}, but CF-Cache-Status is ${cacheStatus ?? "missing"}`
343
+ };
344
+ if (!cacheStatus) return requireCacheStatus ? {
345
+ outcome: "failed",
346
+ error: "response is missing CF-Cache-Status"
347
+ } : { outcome: "warmed" };
113
348
  return {
114
- path: pathname,
349
+ outcome: "failed",
350
+ error: `CF-Cache-Status is ${cacheStatus}`
351
+ };
352
+ }
353
+ function validateBuildIdentity(response, expectedBuildId) {
354
+ if (expectedBuildId !== void 0 && response.headers.get("X-Vinext-Build-Id") !== expectedBuildId) return {
355
+ outcome: "failed",
356
+ error: `response ${VINEXT_CDN_BUILD_ID_HEADER} does not match build ${expectedBuildId} (received ${response.headers.get("X-Vinext-Build-Id") ?? "missing"})`
357
+ };
358
+ return null;
359
+ }
360
+ function isExpectedTerminalStatus(status) {
361
+ return status >= 300 && status < 400 || status === 404;
362
+ }
363
+ function validateRscWarmResponse(response, expectedBuildId, expectedRscBuildId, requireCacheHit = false) {
364
+ const buildIdentityValidation = validateBuildIdentity(response, expectedBuildId);
365
+ if (buildIdentityValidation) return buildIdentityValidation;
366
+ if (expectedRscBuildId !== void 0 && response.headers.get(VINEXT_RSC_BUILD_ID_HEADER) !== expectedRscBuildId) return {
367
+ outcome: "failed",
368
+ error: `response ${VINEXT_RSC_BUILD_ID_HEADER} does not match build ${expectedRscBuildId}`
369
+ };
370
+ if (response.redirected) return {
371
+ outcome: "failed",
372
+ error: "redirected response"
373
+ };
374
+ const terminalResponse = response.status < 200 || response.status >= 300;
375
+ if (terminalResponse) {
376
+ if (!isExpectedTerminalStatus(response.status) || expectedBuildId === void 0 && expectedRscBuildId === void 0) return {
377
+ outcome: "failed",
378
+ error: `HTTP ${response.status}`
379
+ };
380
+ } else if (!response.headers.get("Content-Type")?.toLowerCase().startsWith(VINEXT_RSC_CONTENT_TYPE)) return {
381
+ outcome: "failed",
382
+ error: `expected ${VINEXT_RSC_CONTENT_TYPE} response`
383
+ };
384
+ const cachePolicyValidation = validateCachePolicy(response, true, requireCacheHit);
385
+ if (cachePolicyValidation.outcome !== "warmed") return cachePolicyValidation;
386
+ if (terminalResponse && !response.headers.get("Content-Type")?.toLowerCase().startsWith(VINEXT_RSC_CONTENT_TYPE)) return {
387
+ outcome: "failed",
388
+ error: `expected ${VINEXT_RSC_CONTENT_TYPE} response`
389
+ };
390
+ const vary = new Set((response.headers.get("Vary") ?? "").split(",").map((name) => name.trim().toLowerCase()).filter(Boolean));
391
+ const missingVary = REQUIRED_RSC_VARY_HEADERS.find((name) => !vary.has(name));
392
+ if (missingVary) return {
393
+ outcome: "failed",
394
+ error: `response Vary is missing ${missingVary}`
395
+ };
396
+ return { outcome: "warmed" };
397
+ }
398
+ function validateHtmlWarmResponse(response, expectedBuildId, requireCacheHit = false) {
399
+ const buildIdentityValidation = validateBuildIdentity(response, expectedBuildId);
400
+ if (buildIdentityValidation) return buildIdentityValidation;
401
+ if (response.redirected) return {
402
+ outcome: "failed",
403
+ error: "redirected response"
404
+ };
405
+ if (response.status < 200 || response.status >= 300) {
406
+ if (!isExpectedTerminalStatus(response.status) || expectedBuildId === void 0) return {
407
+ outcome: "failed",
408
+ error: `HTTP ${response.status}`
409
+ };
410
+ }
411
+ const cachePolicyValidation = validateCachePolicy(response, true, requireCacheHit);
412
+ if (cachePolicyValidation.outcome !== "warmed") return cachePolicyValidation;
413
+ return { outcome: "warmed" };
414
+ }
415
+ function validatePagesDataWarmResponse(response, expectedBuildId, requireCacheHit = false) {
416
+ const validation = validateHtmlWarmResponse(response, expectedBuildId, requireCacheHit);
417
+ if (validation.outcome !== "warmed") return validation;
418
+ if (!response.headers.get("Content-Type")?.toLowerCase().startsWith("application/json")) return {
419
+ outcome: "failed",
420
+ error: "expected application/json response"
421
+ };
422
+ return validation;
423
+ }
424
+ function validateReadinessResponse(response, kind, expectedBuildId, expectedRscBuildId) {
425
+ const buildIdentityValidation = validateBuildIdentity(response, expectedBuildId);
426
+ if (buildIdentityValidation?.outcome === "failed") return buildIdentityValidation.error;
427
+ if (kind === "rsc" && expectedRscBuildId !== void 0 && response.headers.get(VINEXT_RSC_BUILD_ID_HEADER) !== expectedRscBuildId) return `response ${VINEXT_RSC_BUILD_ID_HEADER} does not match build ${expectedRscBuildId}`;
428
+ if (response.redirected) return "redirected response";
429
+ if (response.status >= 500) return `HTTP ${response.status}`;
430
+ if (response.status >= 200 && response.status < 300) {
431
+ const contentType = response.headers.get("Content-Type")?.toLowerCase();
432
+ if (kind === "rsc" && !contentType?.startsWith(VINEXT_RSC_CONTENT_TYPE)) return `expected ${VINEXT_RSC_CONTENT_TYPE} response`;
433
+ if (kind === "pages-data" && !contentType?.startsWith("application/json")) return "expected application/json response";
434
+ }
435
+ return null;
436
+ }
437
+ function validatePrerenderReadinessResponse(response, expectedBuildId) {
438
+ const buildIdentityValidation = validateBuildIdentity(response, expectedBuildId);
439
+ if (buildIdentityValidation?.outcome === "failed") return buildIdentityValidation.error;
440
+ if (response.redirected) return "redirected response";
441
+ if (response.status !== 204) return `expected readiness HTTP 204, received ${response.status}`;
442
+ if (response.headers.get(VINEXT_PRERENDER_READINESS_HEADER) !== "1") return `response is missing ${VINEXT_PRERENDER_READINESS_HEADER}: 1`;
443
+ const cacheControl = response.headers.get("Cache-Control");
444
+ if (!cacheControl || !/(?:^|,)\s*no-store\s*(?:,|$)/i.test(cacheControl)) return "readiness response is missing Cache-Control: no-store";
445
+ return null;
446
+ }
447
+ /**
448
+ * Wait until version-override requests consistently reach the uploaded build
449
+ * before any real cache key is filled. Every probe has a unique query key, so
450
+ * an early response cannot make a later readiness attempt pass from cache.
451
+ */
452
+ async function waitForCdnWarmTargetReadiness(options) {
453
+ const rscPath = options.plan.rscPaths[0] ?? options.plan.loadingShellPaths[0];
454
+ const htmlPath = options.plan.paths[0];
455
+ const pagesDataPath = options.plan.pagesDataPaths[0];
456
+ const routeHandlerPath = options.plan.routeHandlerPaths?.[0];
457
+ const kind = rscPath ? "rsc" : htmlPath ? "html" : pagesDataPath ? "pages-data" : "app-route";
458
+ const pathname = rscPath ?? htmlPath ?? pagesDataPath ?? routeHandlerPath;
459
+ if (!pathname) return { ready: true };
460
+ if (options.expectedBuildId === void 0 && options.expectedRscBuildId === void 0) return {
461
+ error: "no response build identity is available for a staged-version readiness probe",
462
+ ready: false
463
+ };
464
+ const headers = new Headers(options.headers);
465
+ const readinessSecret = options.prerenderSecret;
466
+ const useReadinessEndpoint = Boolean(readinessSecret);
467
+ const probePath = useReadinessEndpoint ? VINEXT_PRERENDER_READINESS_PATH : kind === "rsc" ? createCanonicalRscRequestUrl(pathname) : pathname;
468
+ if (readinessSecret) {
469
+ headers.set("Accept", "text/html");
470
+ headers.set(VINEXT_PRERENDER_SECRET_HEADER, readinessSecret);
471
+ } else if (kind === "rsc") for (const [name, value] of createCanonicalRscRequestHeaders(options.deploymentId)) headers.set(name, value);
472
+ else if (kind === "html") headers.set("Accept", "text/html");
473
+ else if (kind === "pages-data") headers.set("Accept", "application/json");
474
+ else headers.set("Accept", "*/*");
475
+ headers.set("Cache-Control", "no-cache");
476
+ headers.set("Pragma", "no-cache");
477
+ const fetchImpl = options.fetchImpl ?? fetch;
478
+ const timeoutMs = Math.max(1, options.timeoutMs ?? 1e4);
479
+ const probeIntervalMs = Math.max(0, options.probeIntervalMs ?? 1e3);
480
+ const requiredConsecutiveSuccesses = Math.max(1, options.requiredConsecutiveSuccesses ?? DEFAULT_STAGED_READINESS_SUCCESSES);
481
+ const readinessRetries = Math.max(0, options.retries ?? 60);
482
+ const maxAttempts = Math.max(requiredConsecutiveSuccesses, options.maxAttempts ?? requiredConsecutiveSuccesses + readinessRetries);
483
+ const phaseTimeoutMs = Math.max(1, options.phaseTimeoutMs ?? 12e4);
484
+ const deadlineAt = Date.now() + phaseTimeoutMs;
485
+ const probeId = randomUUID();
486
+ let consecutiveSuccesses = 0;
487
+ let lastError = "readiness probe did not run";
488
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
489
+ const remainingMs = deadlineAt - Date.now();
490
+ if (remainingMs <= 0) {
491
+ lastError = `staged readiness exceeded its ${phaseTimeoutMs}ms phase deadline`;
492
+ break;
493
+ }
494
+ const url = buildWarmupUrl(options.targetUrl, probePath);
495
+ url.searchParams.set(STAGED_READINESS_QUERY_PARAM, `${probeId}-${attempt}`);
496
+ let response;
497
+ try {
498
+ response = await fetchHeadersWithTimeout(fetchImpl, url, Math.min(timeoutMs, remainingMs), headers, useReadinessEndpoint ? "POST" : "GET");
499
+ const validationError = useReadinessEndpoint ? validatePrerenderReadinessResponse(response, options.expectedBuildId) : validateReadinessResponse(response, kind, options.expectedBuildId, options.expectedRscBuildId);
500
+ if (process.env.VINEXT_CDN_WARM_DEBUG === "1") console.log(` CDN warm readiness attempt ${attempt + 1}: ${url.pathname}${url.search} HTTP ${response.status} build=${response.headers.get(VINEXT_RSC_BUILD_ID_HEADER) ?? response.headers.get("X-Vinext-Build-Id") ?? "missing"}`);
501
+ if (validationError === null) {
502
+ consecutiveSuccesses++;
503
+ if (consecutiveSuccesses >= requiredConsecutiveSuccesses) return { ready: true };
504
+ } else {
505
+ consecutiveSuccesses = 0;
506
+ lastError = validationError;
507
+ }
508
+ } catch (error) {
509
+ consecutiveSuccesses = 0;
510
+ lastError = Date.now() >= deadlineAt ? `staged readiness exceeded its ${phaseTimeoutMs}ms phase deadline` : error instanceof DOMException && error.name === "AbortError" ? `timed out after ${Math.min(timeoutMs, remainingMs)}ms` : error instanceof Error ? error.message : String(error);
511
+ } finally {
512
+ if (response?.body) response.body.cancel().catch(() => {});
513
+ }
514
+ if (attempt + 1 < maxAttempts && probeIntervalMs > 0) {
515
+ const delayMs = Math.min(probeIntervalMs, Math.max(0, deadlineAt - Date.now()));
516
+ if (delayMs <= 0) {
517
+ lastError = `staged readiness exceeded its ${phaseTimeoutMs}ms phase deadline`;
518
+ break;
519
+ }
520
+ await setTimeout$1(delayMs);
521
+ }
522
+ }
523
+ return {
524
+ error: `${lastError}; uploaded build was not stable for ${requiredConsecutiveSuccesses} consecutive probe(s)`,
525
+ ready: false
526
+ };
527
+ }
528
+ function shouldRetryValidationFailure(response, target, options) {
529
+ if (!options.retryPropagationFailures) return isRetryableStatus(response.status, options.retryNotFound);
530
+ const expectedIdentities = [options.expectedBuildId === void 0 ? null : response.headers.get(VINEXT_CDN_BUILD_ID_HEADER) === options.expectedBuildId, !target.kind.startsWith("rsc-") || options.expectedRscBuildId === void 0 ? null : response.headers.get(VINEXT_RSC_BUILD_ID_HEADER) === options.expectedRscBuildId].filter((matches) => matches !== null);
531
+ if (expectedIdentities.some((matches) => matches)) return isRetryableStatus(response.status, false);
532
+ if (expectedIdentities.some((matches) => !matches)) return true;
533
+ return isRetryableStatus(response.status, options.retryNotFound);
534
+ }
535
+ async function warmOnePath(target, options) {
536
+ const url = buildWarmupUrl(options.targetUrl, target.pathname);
537
+ let lastError = "request failed before the first attempt";
538
+ let lastRetryable = true;
539
+ const phaseDeadlineError = () => `CDN warmup exceeded its ${options.phaseTimeoutMs}ms phase deadline`;
540
+ const remainingPhaseMs = () => options.deadlineAt === void 0 ? options.timeoutMs : options.deadlineAt - Date.now();
541
+ const canRetry = (attempt) => attempt < options.retries;
542
+ const waitBeforeRetry = async () => {
543
+ if (options.retryDelayMs <= 0) return remainingPhaseMs() > 0;
544
+ const delayMs = Math.min(options.retryDelayMs, Math.max(0, remainingPhaseMs()));
545
+ if (delayMs <= 0) return false;
546
+ await setTimeout$1(delayMs);
547
+ return remainingPhaseMs() > 0;
548
+ };
549
+ for (let attempt = 0; attempt <= options.retries; attempt++) {
550
+ const remainingMs = remainingPhaseMs();
551
+ if (remainingMs <= 0) return {
552
+ path: target.label,
553
+ ok: false,
554
+ error: phaseDeadlineError(),
555
+ retryable: false
556
+ };
557
+ const attemptTimeoutMs = Math.min(options.timeoutMs, remainingMs);
558
+ try {
559
+ const response = options.requireCacheHit ? await fetchHeadersWithTimeout(options.fetchImpl, url, attemptTimeoutMs, target.headers ?? options.headers) : (await fetchWithTimeout(options.fetchImpl, url, attemptTimeoutMs, target.headers ?? options.headers, "manual")).response;
560
+ if (options.deadlineAt !== void 0 && Date.now() >= options.deadlineAt) return {
561
+ path: target.label,
562
+ ok: false,
563
+ error: phaseDeadlineError(),
564
+ retryable: false
565
+ };
566
+ if (options.requireCacheHit) response.body?.cancel().catch(() => {});
567
+ if (process.env.VINEXT_CDN_WARM_DEBUG === "1") console.log(` CDN warm debug ${target.label} attempt ${attempt + 1}: ${url.pathname}${url.search} HTTP ${response.status} cache=${response.headers.get("CF-Cache-Status") ?? "missing"} ray=${response.headers.get("CF-Ray") ?? "missing"} encoding=${response.headers.get("Content-Encoding") ?? "identity"} rscBuild=${response.headers.get(VINEXT_RSC_BUILD_ID_HEADER) ?? "missing"}`);
568
+ if (target.kind.startsWith("rsc-")) {
569
+ const validation = validateRscWarmResponse(response, options.expectedBuildId, options.expectedRscBuildId, options.requireCacheHit);
570
+ if (validation.outcome === "warmed") return {
571
+ path: target.label,
572
+ ok: true,
573
+ skipped: false
574
+ };
575
+ if (validation.outcome === "skipped") return {
576
+ path: target.label,
577
+ ok: true,
578
+ skipped: true,
579
+ reason: validation.reason
580
+ };
581
+ lastError = validation.error;
582
+ const cacheStatus = response.headers.get("CF-Cache-Status")?.trim().toUpperCase();
583
+ lastRetryable = options.requireCacheHit && ADMITTED_CF_CACHE_STATUSES.has(cacheStatus ?? "") && !REUSABLE_CF_CACHE_STATUSES.has(cacheStatus ?? "") || shouldRetryValidationFailure(response, target, options);
584
+ if (!lastRetryable) break;
585
+ if (!canRetry(attempt)) break;
586
+ if (!await waitBeforeRetry()) return {
587
+ path: target.label,
588
+ ok: false,
589
+ error: phaseDeadlineError(),
590
+ retryable: false
591
+ };
592
+ continue;
593
+ }
594
+ const validation = target.kind === "pages-data" ? validatePagesDataWarmResponse(response, options.expectedBuildId, options.requireCacheHit) : validateHtmlWarmResponse(response, options.expectedBuildId, options.requireCacheHit);
595
+ if (validation.outcome === "warmed") return {
596
+ path: target.label,
597
+ ok: true,
598
+ skipped: false
599
+ };
600
+ if (validation.outcome === "skipped") return {
601
+ path: target.label,
602
+ ok: true,
603
+ skipped: true,
604
+ reason: validation.reason
605
+ };
606
+ lastError = validation.error;
607
+ const cacheStatus = response.headers.get("CF-Cache-Status")?.trim().toUpperCase();
608
+ lastRetryable = options.requireCacheHit && ADMITTED_CF_CACHE_STATUSES.has(cacheStatus ?? "") && !REUSABLE_CF_CACHE_STATUSES.has(cacheStatus ?? "") || shouldRetryValidationFailure(response, target, options);
609
+ if (!lastRetryable) break;
610
+ } catch (error) {
611
+ lastRetryable = true;
612
+ if (error instanceof DOMException && error.name === "AbortError") lastError = options.deadlineAt !== void 0 && Date.now() >= options.deadlineAt ? phaseDeadlineError() : `timed out after ${attemptTimeoutMs}ms`;
613
+ else lastError = error instanceof Error ? error.message : String(error);
614
+ }
615
+ if (!canRetry(attempt)) break;
616
+ if (!await waitBeforeRetry()) return {
617
+ path: target.label,
618
+ ok: false,
619
+ error: phaseDeadlineError(),
620
+ retryable: false
621
+ };
622
+ }
623
+ return {
624
+ path: target.label,
115
625
  ok: false,
116
- error: lastError
626
+ error: lastError,
627
+ retryable: lastRetryable
117
628
  };
118
629
  }
119
630
  async function runWithConcurrency(items, concurrency, fn) {
@@ -131,53 +642,166 @@ async function runWithConcurrency(items, concurrency, fn) {
131
642
  return results;
132
643
  }
133
644
  async function warmCdnCache(options) {
134
- const paths = options.paths;
135
- const concurrency = Math.max(1, options.concurrency ?? 10);
136
- const timeoutMs = Math.max(1, options.timeoutMs ?? 5e3);
137
- const retries = Math.max(0, options.retries ?? 1);
645
+ const targets = await createCdnWarmTargets(options);
646
+ const requests = targets.filter((target) => target.kind !== "html");
647
+ const htmlRequests = targets.filter((target) => target.kind === "html");
648
+ requests.push(...htmlRequests);
649
+ const timeoutMs = Math.max(1, options.timeoutMs ?? 1e4);
650
+ const hasVersionOverride = new Headers(options.headers).has(WORKER_VERSION_OVERRIDE_HEADER);
651
+ const propagatingTarget = options.propagatingTarget ?? hasVersionOverride;
652
+ const concurrency = Math.max(1, options.concurrency ?? 25);
653
+ const normalRetries = Math.max(0, options.retries ?? 1);
654
+ const propagationRetries = Math.max(0, options.retries ?? 60);
655
+ const normalRetryDelayMs = Math.max(0, options.retryDelayMs ?? 0);
656
+ const propagationRetryDelayMs = Math.max(0, options.retryDelayMs ?? 1e3);
138
657
  const fetchImpl = options.fetchImpl ?? fetch;
139
- if (paths.length === 0) return {
658
+ const phaseTimeoutMs = options.phaseTimeoutMs === void 0 ? void 0 : Math.max(1, options.phaseTimeoutMs);
659
+ const deadlineAt = phaseTimeoutMs === void 0 ? void 0 : Date.now() + phaseTimeoutMs;
660
+ if (requests.length === 0) return {
140
661
  total: 0,
141
662
  warmed: 0,
663
+ skipped: 0,
142
664
  failed: 0,
143
- failures: []
144
- };
145
- console.log(`\n Warming CDN cache for ${paths.length} build-discovered path(s)...`);
146
- const results = await runWithConcurrency(paths, concurrency, (pathname) => warmOnePath(pathname, {
147
- targetUrl: options.targetUrl,
148
- timeoutMs,
149
- retries,
150
- fetchImpl,
151
- headers: options.headers
152
- }));
153
- const failures = results.filter((result) => !result.ok).map(({ path, error }) => ({
665
+ failures: [],
666
+ skippedTargets: [],
667
+ warmedPlan: {
668
+ loadingShellPaths: [],
669
+ pagesDataPaths: [],
670
+ paths: [],
671
+ rscPaths: []
672
+ },
673
+ retryPlan: {
674
+ loadingShellPaths: [],
675
+ pagesDataPaths: [],
676
+ paths: [],
677
+ rscPaths: []
678
+ }
679
+ };
680
+ console.log(`\n Warming CDN cache with ${requests.length} discovered request(s)...`);
681
+ const progress = new CdnOperationProgress();
682
+ let completedRequests = 0;
683
+ progress.update(0, requests.length, "starting warmup");
684
+ const warmTarget = (target, retryMode = "normal") => {
685
+ const isPropagationRequest = retryMode !== "normal";
686
+ const retries = retryMode === "propagation-retry" ? Math.max(0, propagationRetries - 1) : retryMode === "propagation-pass" ? 0 : normalRetries;
687
+ return warmOnePath(target, {
688
+ targetUrl: options.targetUrl,
689
+ timeoutMs,
690
+ retries,
691
+ deadlineAt,
692
+ fetchImpl,
693
+ headers: options.headers,
694
+ expectedBuildId: options.expectedBuildId,
695
+ expectedRscBuildId: options.expectedRscBuildId,
696
+ retryPropagationFailures: isPropagationRequest,
697
+ retryDelayMs: isPropagationRequest ? propagationRetryDelayMs : normalRetryDelayMs,
698
+ retryNotFound: isPropagationRequest,
699
+ phaseTimeoutMs,
700
+ requireCacheHit: options.requireCacheHit === true
701
+ });
702
+ };
703
+ const warmRequest = async (target, retryMode = "normal") => {
704
+ const result = await warmTarget(target, retryMode);
705
+ completedRequests++;
706
+ progress.update(completedRequests, requests.length, target.label);
707
+ return result;
708
+ };
709
+ const warmPropagatingPass = async (targets) => {
710
+ const results = await runWithConcurrency(targets, concurrency, (target) => warmRequest(target, "propagation-pass"));
711
+ const retryableFailed = targets.map((target, index) => ({
712
+ index,
713
+ result: results[index],
714
+ target
715
+ })).filter((entry) => !entry.result.ok).filter(({ result }) => result.retryable);
716
+ if (retryableFailed.length === 0 || propagationRetries === 0) return results;
717
+ progress.finish();
718
+ console.log(` CDN warmup: retrying ${retryableFailed.length} failed request(s) after completing the initial pass...`);
719
+ let completedRetries = 0;
720
+ progress.update(0, retryableFailed.length, "starting retry pass", "Retrying CDN cache");
721
+ const retried = await runWithConcurrency(retryableFailed.map(({ target }) => target), concurrency, async (target) => {
722
+ const result = await warmTarget(target, "propagation-retry");
723
+ completedRetries++;
724
+ progress.update(completedRetries, retryableFailed.length, target.label, "Retrying CDN cache");
725
+ return result;
726
+ });
727
+ progress.finish();
728
+ for (const [retryIndex, { index }] of retryableFailed.entries()) results[index] = retried[retryIndex];
729
+ return results;
730
+ };
731
+ let results;
732
+ if (propagatingTarget) results = await warmPropagatingPass(requests);
733
+ else results = await runWithConcurrency(requests, concurrency, (target) => warmRequest(target));
734
+ progress.finish();
735
+ const failedRequests = requests.map((target, index) => ({
736
+ result: results[index],
737
+ target
738
+ })).filter((entry) => !entry.result.ok);
739
+ const failures = failedRequests.map(({ result: { path, error } }) => ({
154
740
  path,
155
741
  error
156
742
  }));
157
- const warmed = results.length - failures.length;
158
- console.log(` CDN warmup: ${warmed}/${paths.length} path(s) warmed.`);
743
+ const skippedResults = results.filter((result) => result.ok && result.skipped);
744
+ const skippedTargets = requests.filter((_target, index) => {
745
+ const result = results[index];
746
+ return result.ok && result.skipped;
747
+ });
748
+ const warmedRequests = requests.filter((_target, index) => {
749
+ const result = results[index];
750
+ return result.ok && !result.skipped;
751
+ });
752
+ const warmed = results.length - failures.length - skippedResults.length;
753
+ const warmedRouteHandlerPaths = warmedRequests.filter((target) => target.kind === "app-route").map((target) => target.sourcePathname);
754
+ const failedRouteHandlerPaths = failedRequests.filter(({ target }) => target.kind === "app-route").map(({ target }) => target.sourcePathname);
755
+ console.log(` CDN warmup: ${warmed} warmed, ${skippedResults.length} skipped, ${failures.length} failed.`);
756
+ for (const skipped of skippedResults.slice(0, 5)) console.log(` CDN warmup skipped ${skipped.path}: ${skipped.reason}`);
159
757
  if (failures.length > 0) {
160
758
  for (const failure of failures.slice(0, 5)) console.warn(` CDN warmup failed for ${failure.path}: ${failure.error}`);
161
759
  if (failures.length > 5) console.warn(` CDN warmup: ${failures.length - 5} additional failure(s) omitted.`);
162
760
  }
163
761
  const result = {
164
- total: paths.length,
762
+ total: requests.length,
165
763
  warmed,
764
+ skipped: skippedResults.length,
166
765
  failed: failures.length,
167
- failures
766
+ failures,
767
+ skippedTargets,
768
+ warmedPlan: {
769
+ loadingShellPaths: warmedRequests.filter((target) => target.kind === "rsc-loading-shell").map((target) => target.sourcePathname),
770
+ pagesDataPaths: warmedRequests.filter((target) => target.kind === "pages-data").map((target) => target.sourcePathname),
771
+ paths: warmedRequests.filter((target) => target.kind === "html").map((target) => target.sourcePathname),
772
+ rscPaths: warmedRequests.filter((target) => target.kind === "rsc-full").map((target) => target.sourcePathname),
773
+ ...warmedRouteHandlerPaths.length > 0 ? { routeHandlerPaths: warmedRouteHandlerPaths } : {}
774
+ },
775
+ retryPlan: {
776
+ loadingShellPaths: failedRequests.filter(({ target }) => target.kind === "rsc-loading-shell").map(({ target }) => target.sourcePathname),
777
+ pagesDataPaths: failedRequests.filter(({ target }) => target.kind === "pages-data").map(({ target }) => target.sourcePathname),
778
+ paths: failedRequests.filter(({ target }) => target.kind === "html").map(({ target }) => target.sourcePathname),
779
+ rscPaths: failedRequests.filter(({ target }) => target.kind === "rsc-full").map(({ target }) => target.sourcePathname),
780
+ ...failedRouteHandlerPaths.length > 0 ? { routeHandlerPaths: failedRouteHandlerPaths } : {}
781
+ }
168
782
  };
169
- if (options.strict && failures.length > 0) throw new Error(`CDN warmup failed for ${failures.length}/${paths.length} path(s). First failure: ${failures[0].path}: ${failures[0].error}`);
783
+ if (options.strict && failures.length > 0) throw new Error(`CDN warmup failed for ${failures.length}/${requests.length} request(s). First failure: ${failures[0].path}: ${failures[0].error}`);
170
784
  return result;
171
785
  }
172
786
  async function warmCdnCacheFromPrerender(options) {
173
- const paths = readPrerenderWarmPaths(options.root, {
787
+ const plan = readPrerenderWarmPlan(options.root, {
174
788
  includeFallbackShells: options.includeFallbackShells,
175
789
  strict: options.strict
176
790
  });
791
+ const warmPlan = {
792
+ deploymentId: plan.deploymentId,
793
+ loadingShellPaths: plan.loadingShellPaths,
794
+ pagesDataPaths: plan.pagesDataPaths,
795
+ paths: plan.paths,
796
+ routeHandlerPaths: plan.routeHandlerPaths,
797
+ rscPaths: plan.rscPaths
798
+ };
177
799
  return warmCdnCache({
178
800
  ...options,
179
- paths
801
+ ...warmPlan,
802
+ expectedBuildId: options.expectedBuildId ?? (options.validateBuildIdentity === false ? void 0 : plan.buildIdentity),
803
+ expectedRscBuildId: options.expectedRscBuildId ?? plan.rscBuildId
180
804
  });
181
805
  }
182
806
  //#endregion
183
- export { DEFAULT_CDN_WARM_TIMEOUT_MS, buildWarmupUrl, getWarmPathsFromPrerenderManifest, readPrerenderWarmPaths, warmCdnCache, warmCdnCacheFromPrerender };
807
+ export { CdnOperationProgress, DEFAULT_CDN_WARM_CONCURRENCY, DEFAULT_CDN_WARM_TIMEOUT_MS, DEFAULT_STAGED_READINESS_INTERVAL_MS, DEFAULT_STAGED_READINESS_PHASE_TIMEOUT_MS, DEFAULT_STAGED_READINESS_RETRIES, buildWarmupUrl, createCdnWarmTargets, getWarmPathsFromPrerenderManifest, readPrerenderWarmPaths, readPrerenderWarmPlan, waitForCdnWarmTargetReadiness, warmCdnCache, warmCdnCacheFromPrerender };