@vinext/cloudflare 1.0.0-beta.1 → 1.0.0-beta.11

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.
Files changed (66) hide show
  1. package/README.md +193 -1
  2. package/dist/cache/cdn-adapter-config.d.ts +31 -0
  3. package/dist/cache/cdn-adapter-config.js +95 -0
  4. package/dist/cache/cdn-adapter.d.ts +48 -15
  5. package/dist/cache/cdn-adapter.js +50 -13
  6. package/dist/cache/cdn-adapter.runtime.d.ts +26 -4
  7. package/dist/cache/cdn-adapter.runtime.js +150 -37
  8. package/dist/cache/cdn-adapter.worker.d.ts +31 -0
  9. package/dist/cache/cdn-adapter.worker.js +373 -0
  10. package/dist/cache/cdn-build-id.d.ts +6 -0
  11. package/dist/cache/cdn-build-id.js +9 -0
  12. package/dist/cache/config.d.ts +137 -0
  13. package/dist/cache/config.js +87 -0
  14. package/dist/cache/kv-data-adapter.d.ts +32 -8
  15. package/dist/cache/kv-data-adapter.js +5 -1
  16. package/dist/cache/kv-data-adapter.runtime.d.ts +45 -13
  17. package/dist/cache/kv-data-adapter.runtime.js +132 -52
  18. package/dist/cache/kv-key.d.ts +15 -0
  19. package/dist/cache/kv-key.js +43 -0
  20. package/dist/cache/response-store-adapter.d.ts +73 -0
  21. package/dist/cache/response-store-adapter.js +64 -0
  22. package/dist/cache/response-store-adapter.self-contained.worker.d.ts +18 -0
  23. package/dist/cache/response-store-adapter.self-contained.worker.js +12 -0
  24. package/dist/cache/response-store-adapter.service-binding.worker.d.ts +16 -0
  25. package/dist/cache/response-store-adapter.service-binding.worker.js +11 -0
  26. package/dist/cache/response-store-adapter.worker.d.ts +14 -0
  27. package/dist/cache/response-store-adapter.worker.js +278 -0
  28. package/dist/cache/response-store-cdn.runtime.d.ts +5 -0
  29. package/dist/cache/response-store-cdn.runtime.js +41 -0
  30. package/dist/cache/response-store-data.runtime.d.ts +34 -0
  31. package/dist/cache/response-store-data.runtime.js +300 -0
  32. package/dist/cacheability-artifact.d.ts +10 -0
  33. package/dist/cacheability-artifact.js +215 -0
  34. package/dist/cacheability-manifest-limits.d.ts +4 -0
  35. package/dist/cacheability-manifest-limits.js +7 -0
  36. package/dist/cacheability-probe.d.ts +49 -0
  37. package/dist/cacheability-probe.js +660 -0
  38. package/dist/cdn-warm.d.ts +110 -12
  39. package/dist/cdn-warm.js +880 -82
  40. package/dist/cli.d.ts +1 -1
  41. package/dist/cli.js +15 -1
  42. package/dist/deploy-config.d.ts +14 -11
  43. package/dist/deploy-config.js +24 -5
  44. package/dist/deploy-help.d.ts +3 -4
  45. package/dist/deploy-help.js +65 -22
  46. package/dist/deploy.d.ts +139 -42
  47. package/dist/deploy.js +946 -132
  48. package/dist/images/images-optimizer.d.ts +5 -5
  49. package/dist/images/images-optimizer.runtime.d.ts +1 -5
  50. package/dist/prerender-kv-populate.d.ts +3 -4
  51. package/dist/prerender-kv-populate.js +51 -18
  52. package/dist/tpr.d.ts +34 -70
  53. package/dist/tpr.js +50 -603
  54. package/dist/utils/cache-control-metadata.d.ts +3 -4
  55. package/dist/version-deploy.d.ts +46 -17
  56. package/dist/version-deploy.js +151 -14
  57. package/dist/version-headers.d.ts +2 -0
  58. package/dist/version-headers.js +2 -0
  59. package/dist/worker-deployment-url.d.ts +9 -0
  60. package/dist/worker-deployment-url.js +45 -0
  61. package/dist/workers-dev-url.d.ts +2 -3
  62. package/dist/wrangler-config.d.ts +19 -0
  63. package/dist/wrangler-config.js +294 -0
  64. package/dist/wrangler-version-metadata.d.ts +7 -0
  65. package/dist/wrangler-version-metadata.js +9 -0
  66. package/package.json +21 -11
package/dist/cdn-warm.js CHANGED
@@ -1,9 +1,28 @@
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 { cacheabilityRoutePathname } from "vinext/internal/server/cacheability-manifest";
5
+ import { randomUUID } from "node:crypto";
6
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
7
+ import { VINEXT_CACHE_HEADER, VINEXT_PRERENDER_READINESS_HEADER, VINEXT_PRERENDER_READINESS_PATH, VINEXT_PRERENDER_SECRET_HEADER } from "vinext/internal/server/headers";
3
8
  import { PRERENDER_PATHS_MANIFEST } from "vinext/internal/build/prerender-paths";
4
9
  import { getPrerenderedConcretePaths, readPrerenderManifest } from "vinext/internal/server/prerender-manifest";
10
+ import { VINEXT_RSC_BUILD_ID_HEADER, VINEXT_RSC_CONTENT_TYPE, VINEXT_RSC_VARY_HEADER as VINEXT_RSC_VARY_HEADER$1, createCanonicalLoadingShellRscRequestHeaders, createCanonicalRscRequestHeaders, createCanonicalRscRequestUrl, createRscRequestUrl } from "vinext/internal/server/app-rsc-cache-busting";
11
+ import { isNonCacheableCacheControl } from "vinext/shims/cdn-cache";
12
+ import { normalizePathTrailingSlash } from "vinext/shims/url-utils";
5
13
  //#region src/cdn-warm.ts
6
- const DEFAULT_CDN_WARM_TIMEOUT_MS = 5e3;
14
+ const DEFAULT_CDN_WARM_CONCURRENCY = 25;
15
+ const DEFAULT_CDN_WARM_TIMEOUT_MS = 1e4;
16
+ const DEFAULT_STAGED_READINESS_RETRIES = 60;
17
+ const DEFAULT_STAGED_READINESS_INTERVAL_MS = 1e3;
18
+ const DEFAULT_STAGED_READINESS_PHASE_TIMEOUT_MS = 12e4;
19
+ const DEFAULT_STAGED_READINESS_SUCCESSES = 6;
20
+ const STAGED_READINESS_QUERY_PARAM = "__vinext_cdn_warm_readiness";
21
+ function applyWarmPathConfig(pathname, config) {
22
+ const withBasePath = config.basePath ? pathname === "/" ? config.basePath : `${config.basePath}${pathname}` : pathname;
23
+ const normalized = normalizePathTrailingSlash(withBasePath, config.trailingSlash === true);
24
+ return new URL(normalized, "http://vinext.local").pathname;
25
+ }
7
26
  function readBuiltBuildId(root) {
8
27
  try {
9
28
  const buildId = fs.readFileSync(path.join(root, "dist", "server", "BUILD_ID"), "utf-8").trim();
@@ -19,52 +38,83 @@ function readPrerenderPathManifest(manifestPath) {
19
38
  const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
20
39
  if (!parsed || typeof parsed !== "object") return null;
21
40
  const manifest = parsed;
22
- if (!Array.isArray(manifest.paths)) return null;
41
+ 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("/")) && (route.cacheabilityProbe.routeMayResolve === void 0 || typeof route.cacheabilityProbe.routeMayResolve === "boolean") && (route.cacheabilityProbe.requestStageMayTerminate === void 0 || typeof route.cacheabilityProbe.requestStageMayTerminate === "boolean")))) || 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
42
  return manifest;
24
43
  } catch (error) {
25
44
  console.warn(`[vinext] Failed to read prerender path manifest at ${manifestPath}:`, error);
26
45
  return null;
27
46
  }
28
47
  }
29
- function readPrerenderPathWarmPaths(root, options) {
30
- const manifest = readPrerenderPathManifest(path.join(root, "dist", "server", PRERENDER_PATHS_MANIFEST));
31
- if (!manifest) return null;
48
+ function createPrerenderPathWarmPaths(root, manifest, options) {
32
49
  const builtBuildId = readBuiltBuildId(root);
33
50
  if (!manifest.buildId || !builtBuildId || manifest.buildId !== builtBuildId) {
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 createPrerenderWarmPlan(root, manifest, options) {
63
+ const pathPlan = createPrerenderPathWarmPaths(root, manifest, options);
64
+ if (!pathPlan) {
65
+ const message = "[vinext] CDN warmup skipped: prerender path discovery is invalid.";
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.";
73
+ const supportsCanonicalRsc = (manifest.responseVary === "verbatim" || options?.includeCanonicalRsc === true) && manifest.rscPaths !== void 0 && manifest.rscBuildId !== void 0;
74
+ const applyConfig = (pathname) => applyWarmPathConfig(pathname, manifest);
75
+ const routePatterns = manifest.routePatterns ? Object.fromEntries(Object.entries(manifest.routePatterns).map(([pathname, route]) => [pathname.includes("/_next/data/") ? pathname : applyConfig(pathname), route])) : void 0;
76
+ let htmlPaths = pathPlan.paths;
77
+ if (options?.includeFallbackShells === true) {
78
+ const prerenderManifest = readPrerenderManifest(path.join(root, "dist", "server", "vinext-prerender.json"));
79
+ if (!prerenderManifest) console.warn("[vinext] CDN warmup fallback shells requested, but prerender manifest not found; warming build-discovered paths only.");
80
+ else if (!prerenderManifest.buildId || prerenderManifest.buildId !== manifest.buildId) {
81
+ const message = "[vinext] CDN warmup skipped: prerender manifest buildId does not match dist/server/BUILD_ID.";
82
+ if (options.strict) throw new Error(message);
83
+ console.warn(message);
84
+ htmlPaths = [];
85
+ } else htmlPaths = getPrerenderedConcretePaths(prerenderManifest, { includeFallbackShells: true }).filter((pathname) => pathname.startsWith("/")).filter((pathname) => !manifest.excludedWarmPaths?.includes(pathname)).map(applyConfig);
86
+ }
87
+ return {
88
+ ...manifest.appPaths ? { appPaths: manifest.appPaths.map(applyConfig) } : {},
89
+ buildId: manifest.buildId,
90
+ ...manifest.buildIdentity ? { buildIdentity: manifest.buildIdentity } : {},
91
+ ...manifest.deploymentId ? { deploymentId: manifest.deploymentId } : {},
92
+ ...manifest.fallbackRoutePatterns ? { fallbackRoutePatterns: manifest.fallbackRoutePatterns } : {},
93
+ loadingShellPaths: supportsCanonicalRsc ? (manifest.loadingShellPaths ?? []).map(applyConfig) : [],
94
+ ...manifest.pagesDataPaths ? { pagesDataPaths: manifest.pagesDataPaths } : {},
95
+ ...manifest.pagesPaths ? { pagesPaths: manifest.pagesPaths.map(applyConfig) } : {},
96
+ paths: htmlPaths,
97
+ ...supportsCanonicalRsc ? { rscBuildId: manifest.rscBuildId } : {},
98
+ rscPaths: supportsCanonicalRsc ? manifest.rscPaths.map(applyConfig) : [],
99
+ ...manifest.routeHandlerPaths ? { routeHandlerPaths: manifest.routeHandlerPaths.map(applyConfig) } : {},
100
+ ...routePatterns ? { routePatterns } : {}
101
+ };
102
+ }
103
+ function readPrerenderWarmPlan(root, options) {
104
+ const manifest = readPrerenderPathManifest(path.join(root, "dist", "server", PRERENDER_PATHS_MANIFEST));
105
+ if (!manifest) {
106
+ const message = "[vinext] CDN warmup skipped: prerender path manifest not found.";
63
107
  if (options?.strict) throw new Error(message);
64
- console.warn(message);
65
- return [];
108
+ return {
109
+ loadingShellPaths: [],
110
+ paths: [],
111
+ rscPaths: []
112
+ };
66
113
  }
67
- return getPrerenderedConcretePaths(manifest, options);
114
+ return createPrerenderWarmPlan(root, manifest, options);
115
+ }
116
+ function readPrerenderWarmPaths(root, options) {
117
+ return readPrerenderWarmPlan(root, options).paths;
68
118
  }
69
119
  function getWarmPathsFromPrerenderManifest(manifest, options) {
70
120
  return getPrerenderedConcretePaths(manifest, options);
@@ -75,45 +125,676 @@ function normalizeWarmPath(pathname) {
75
125
  function buildWarmupUrl(targetUrl, pathname) {
76
126
  return new URL(normalizeWarmPath(pathname), targetUrl.endsWith("/") ? targetUrl : `${targetUrl}/`);
77
127
  }
78
- function isRetryableStatus(status) {
79
- return status === 408 || status === 429 || status >= 500;
128
+ function isRetryableStatus(status, retryNotFound) {
129
+ return status === 408 || status === 429 || status >= 500 || retryNotFound && status === 404;
130
+ }
131
+ const WORKER_VERSION_OVERRIDE_HEADER = "Cloudflare-Workers-Version-Overrides";
132
+ async function fetchWithTimeout(fetchImpl, url, timeoutMs, headers, redirect) {
133
+ const controller = new AbortController();
134
+ let response;
135
+ let timer;
136
+ const requestHeaders = new Headers(headers);
137
+ requestHeaders.set("User-Agent", "vinext-cloudflare-cdn-warm");
138
+ try {
139
+ const timedOut = new Promise((_resolve, reject) => {
140
+ timer = setTimeout(() => {
141
+ controller.abort();
142
+ reject(new DOMException(`Timed out after ${timeoutMs}ms`, "AbortError"));
143
+ }, timeoutMs);
144
+ });
145
+ return await Promise.race([(async () => {
146
+ response = await fetchImpl(url, {
147
+ method: "GET",
148
+ redirect,
149
+ headers: requestHeaders,
150
+ signal: controller.signal
151
+ });
152
+ return {
153
+ body: await response.arrayBuffer(),
154
+ response
155
+ };
156
+ })(), timedOut]);
157
+ } finally {
158
+ if (timer !== void 0) clearTimeout(timer);
159
+ if (controller.signal.aborted && response?.body) response.body.cancel().catch(() => {});
160
+ }
80
161
  }
81
- async function fetchWithTimeout(fetchImpl, url, timeoutMs, headers) {
162
+ async function fetchHeadersWithTimeout(fetchImpl, url, timeoutMs, headers, method = "GET") {
82
163
  const controller = new AbortController();
83
- const timer = setTimeout(() => controller.abort(), timeoutMs);
84
164
  const requestHeaders = new Headers(headers);
85
165
  requestHeaders.set("User-Agent", "vinext-cloudflare-cdn-warm");
166
+ let timer;
86
167
  try {
87
- return await fetchImpl(url, {
88
- method: "GET",
89
- redirect: "follow",
168
+ const timedOut = new Promise((_resolve, reject) => {
169
+ timer = setTimeout(() => {
170
+ controller.abort();
171
+ reject(new DOMException(`Timed out after ${timeoutMs}ms`, "AbortError"));
172
+ }, timeoutMs);
173
+ });
174
+ return await Promise.race([fetchImpl(url, {
175
+ method,
176
+ redirect: "manual",
90
177
  headers: requestHeaders,
91
178
  signal: controller.signal
92
- });
179
+ }), timedOut]);
93
180
  } 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
181
+ if (timer !== void 0) clearTimeout(timer);
182
+ }
183
+ }
184
+ const MAX_CDN_WARM_REPORT_ROUTES = 10;
185
+ const MAX_CDN_WARM_REPORT_PATTERN_WIDTH = 48;
186
+ const CDN_WARM_ROUTE_KIND_LABELS = {
187
+ "app-page": "App page",
188
+ "app-route": "Route Handler",
189
+ "pages-page": "Pages page"
190
+ };
191
+ function formatCount(count, singular, plural = `${singular}s`) {
192
+ return `${count.toLocaleString("en-US")} ${count === 1 ? singular : plural}`;
193
+ }
194
+ function formatNumber(count) {
195
+ return count.toLocaleString("en-US");
196
+ }
197
+ function truncateMiddle(value, maxLength) {
198
+ if (value.length <= maxLength) return value;
199
+ const remaining = maxLength - 1;
200
+ const startLength = Math.ceil(remaining / 2);
201
+ return `${value.slice(0, startLength)}…${value.slice(-(remaining - startLength))}`;
202
+ }
203
+ function printCdnWarmTable(headings, rows, rightAlignedFrom) {
204
+ const widths = headings.map((heading, index) => Math.max(heading.length, ...rows.map((row) => row[index]?.length ?? 0)));
205
+ for (const row of [headings, ...rows]) console.log(` ${row.map((value, index) => index >= rightAlignedFrom ? value.padStart(widths[index]) : value.padEnd(widths[index])).join(" ")}`);
206
+ }
207
+ function createCdnWarmRouteReport(targets, outcomes) {
208
+ const routes = /* @__PURE__ */ new Map();
209
+ for (const [index, target] of targets.entries()) {
210
+ const key = target.route ? `${target.route.kind}\0${target.route.pattern}` : "unmatched";
211
+ const route = routes.get(key) ?? {
212
+ failed: 0,
213
+ kind: target.route ? CDN_WARM_ROUTE_KIND_LABELS[target.route.kind] : "Other",
214
+ key,
215
+ pattern: target.route?.pattern ?? "Other discovered requests",
216
+ paths: /* @__PURE__ */ new Set(),
217
+ skipped: 0,
218
+ total: 0,
219
+ warmed: 0
106
220
  };
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);
221
+ route.paths.add(target.route?.cacheabilityProbe?.concretePathname ?? cacheabilityRoutePathname(target.pathname, target.kind));
222
+ route.total++;
223
+ const outcome = outcomes?.[index];
224
+ if (outcome) route[outcome]++;
225
+ routes.set(key, route);
226
+ }
227
+ return [...routes.values()].sort((left, right) => right.total - left.total || left.key.localeCompare(right.key));
228
+ }
229
+ function printCdnWarmRouteReport(targets, outcomes) {
230
+ if (!targets.some((target) => target.route)) return;
231
+ const routes = createCdnWarmRouteReport(targets, outcomes);
232
+ if (routes.length === 0) return;
233
+ console.log(` CDN warmup ${outcomes ? "result" : "plan"} by route:`);
234
+ const visible = routes.slice(0, MAX_CDN_WARM_REPORT_ROUTES);
235
+ const routeColumns = (route) => [truncateMiddle(route.pattern, MAX_CDN_WARM_REPORT_PATTERN_WIDTH), route.kind];
236
+ if (outcomes) printCdnWarmTable([
237
+ "Route pattern",
238
+ "Kind",
239
+ "Warmed",
240
+ "Skipped",
241
+ "Failed"
242
+ ], visible.map((route) => [
243
+ ...routeColumns(route),
244
+ `${formatNumber(route.warmed)}/${formatNumber(route.total)}`,
245
+ formatNumber(route.skipped),
246
+ formatNumber(route.failed)
247
+ ]), 2);
248
+ else printCdnWarmTable([
249
+ "Route pattern",
250
+ "Kind",
251
+ "Paths",
252
+ "Entries"
253
+ ], visible.map((route) => [
254
+ ...routeColumns(route),
255
+ formatNumber(route.paths.size),
256
+ formatNumber(route.total)
257
+ ]), 2);
258
+ const omitted = routes.slice(MAX_CDN_WARM_REPORT_ROUTES);
259
+ if (omitted.length > 0) {
260
+ const omittedEntries = omitted.reduce((total, route) => total + route.total, 0);
261
+ console.log(` ... ${formatCount(omitted.length, "additional route pattern")} omitted (${formatCount(omittedEntries, "cache entry", "cache entries")})`);
262
+ }
263
+ }
264
+ function cdnWarmTargetKey(target) {
265
+ return `${target.kind}\0${target.sourcePathname}`;
266
+ }
267
+ async function createCdnWarmTargets(options) {
268
+ const requests = [];
269
+ const fullRscPaths = new Set(options.rscPaths ?? []);
270
+ const loadingShellPaths = new Set(options.loadingShellPaths ?? []);
271
+ const commonHeaders = new Headers(options.headers);
272
+ for (const pathname of /* @__PURE__ */ new Set([...fullRscPaths, ...loadingShellPaths])) {
273
+ if (fullRscPaths.has(pathname)) {
274
+ const rscHeaders = new Headers(commonHeaders);
275
+ for (const [name, value] of createCanonicalRscRequestHeaders(options.deploymentId)) rscHeaders.set(name, value);
276
+ requests.push({
277
+ headers: rscHeaders,
278
+ kind: "rsc-full",
279
+ label: `${pathname} (RSC full)`,
280
+ pathname: createCanonicalRscRequestUrl(pathname),
281
+ sourcePathname: pathname,
282
+ route: options.routePatterns?.[pathname]
283
+ });
284
+ }
285
+ if (loadingShellPaths.has(pathname)) {
286
+ const loadingHeaders = new Headers(commonHeaders);
287
+ for (const [name, value] of createCanonicalLoadingShellRscRequestHeaders(options.deploymentId)) loadingHeaders.set(name, value);
288
+ requests.push({
289
+ headers: loadingHeaders,
290
+ kind: "rsc-loading-shell",
291
+ label: `${pathname} (RSC loading shell)`,
292
+ pathname: await createRscRequestUrl(pathname, loadingHeaders),
293
+ sourcePathname: pathname,
294
+ route: options.routePatterns?.[pathname]
295
+ });
296
+ }
297
+ }
298
+ for (const pathname of new Set(options.paths)) {
299
+ const htmlHeaders = new Headers(commonHeaders);
300
+ htmlHeaders.set("Accept", "text/html");
301
+ requests.push({
302
+ headers: htmlHeaders,
303
+ kind: "html",
304
+ label: pathname,
305
+ pathname,
306
+ sourcePathname: pathname,
307
+ route: options.routePatterns?.[pathname]
308
+ });
309
+ }
310
+ for (const pathname of new Set(options.pagesDataPaths ?? [])) {
311
+ const dataHeaders = new Headers(commonHeaders);
312
+ dataHeaders.set("Accept", "application/json");
313
+ requests.push({
314
+ headers: dataHeaders,
315
+ kind: "pages-data",
316
+ label: `${pathname} (Pages data)`,
317
+ pathname,
318
+ sourcePathname: pathname,
319
+ route: options.routePatterns?.[pathname]
320
+ });
321
+ }
322
+ for (const pathname of new Set(options.routeHandlerPaths ?? [])) {
323
+ const routeHeaders = new Headers(commonHeaders);
324
+ routeHeaders.set("Accept", "*/*");
325
+ requests.push({
326
+ headers: routeHeaders,
327
+ kind: "app-route",
328
+ label: `${pathname} (Route Handler)`,
329
+ pathname,
330
+ sourcePathname: pathname,
331
+ route: options.routePatterns?.[pathname]
332
+ });
333
+ }
334
+ return requests;
335
+ }
336
+ var CdnOperationProgress = class {
337
+ isTTY = process.stderr.isTTY;
338
+ lastLineLength = 0;
339
+ update(completed, total, label, phase = "Warming CDN cache") {
340
+ if (!this.isTTY) return;
341
+ const percent = total > 0 ? Math.floor(completed / total * 100) : 0;
342
+ const filled = Math.floor(percent / 5);
343
+ const bar = `[${"█".repeat(filled)}${" ".repeat(20 - filled)}]`;
344
+ const shortLabel = label.length > 40 ? `…${label.slice(-39)}` : label;
345
+ const line = `${phase}... ${bar} ${String(completed).padStart(String(total).length)}/${total} ${shortLabel}`;
346
+ process.stderr.write(`\r${line.padEnd(this.lastLineLength)}`);
347
+ this.lastLineLength = line.length;
348
+ }
349
+ finish() {
350
+ if (!this.isTTY || this.lastLineLength === 0) return;
351
+ process.stderr.write(`\r${" ".repeat(this.lastLineLength)}\r`);
352
+ this.lastLineLength = 0;
353
+ }
354
+ };
355
+ const REQUIRED_RSC_VARY_HEADERS = VINEXT_RSC_VARY_HEADER$1.split(",").map((name) => name.trim().toLowerCase());
356
+ const ADMITTED_CF_CACHE_STATUSES = /* @__PURE__ */ new Set([
357
+ "HIT",
358
+ "MISS",
359
+ "EXPIRED",
360
+ "REVALIDATED",
361
+ "UPDATING"
362
+ ]);
363
+ const REUSABLE_CF_CACHE_STATUSES = /* @__PURE__ */ new Set([
364
+ "HIT",
365
+ "REVALIDATED",
366
+ "UPDATING"
367
+ ]);
368
+ const TRANSITIONAL_VINEXT_CACHE_STATUSES = /* @__PURE__ */ new Set(["MISS", "UPDATING"]);
369
+ const NON_CACHEABLE_CF_CACHE_STATUSES = /* @__PURE__ */ new Set(["BYPASS"]);
370
+ const CDN_CACHE_POLICY_HEADERS = [
371
+ "Cloudflare-CDN-Cache-Control",
372
+ "CDN-Cache-Control",
373
+ "Cache-Control"
374
+ ];
375
+ function splitCacheControlDirectives(value) {
376
+ const directives = [];
377
+ let start = 0;
378
+ let quoted = false;
379
+ let escaped = false;
380
+ for (let index = 0; index < value.length; index++) {
381
+ const character = value[index];
382
+ if (escaped) escaped = false;
383
+ else if (quoted && character === "\\") escaped = true;
384
+ else if (character === "\"") quoted = !quoted;
385
+ else if (character === "," && !quoted) {
386
+ directives.push(value.slice(start, index));
387
+ start = index + 1;
388
+ }
389
+ }
390
+ directives.push(value.slice(start));
391
+ return directives;
392
+ }
393
+ function hasFieldQualifiedSetCookie(value) {
394
+ return splitCacheControlDirectives(value).some((directive) => {
395
+ const separator = directive.indexOf("=");
396
+ if (separator === -1) return false;
397
+ const name = directive.slice(0, separator).trim().toLowerCase();
398
+ if (name !== "private" && name !== "no-cache") return false;
399
+ const rawFields = directive.slice(separator + 1).trim();
400
+ return (rawFields.startsWith("\"") && rawFields.endsWith("\"") ? rawFields.slice(1, -1) : rawFields).split(",").some((field) => field.trim().toLowerCase() === "set-cookie");
401
+ });
402
+ }
403
+ function validateCachePolicy(response, requireCacheStatus, requireCacheHit = false) {
404
+ const effectivePolicy = CDN_CACHE_POLICY_HEADERS.map((name) => ({
405
+ name,
406
+ value: response.headers.get(name)
407
+ })).find((entry) => entry.value !== null);
408
+ const nonCacheableHeaders = effectivePolicy && isNonCacheableCacheControl(effectivePolicy.value) ? [effectivePolicy.name] : [];
409
+ const hasSetCookie = response.headers.has("Set-Cookie");
410
+ const cacheStatus = response.headers.get("CF-Cache-Status")?.trim().toUpperCase();
411
+ if (requireCacheHit && !REUSABLE_CF_CACHE_STATUSES.has(cacheStatus ?? "")) return {
412
+ outcome: "failed",
413
+ error: `CF-Cache-Status is ${cacheStatus ?? "missing"}; the cache fill is not reusable`
414
+ };
415
+ if (cacheStatus && ADMITTED_CF_CACHE_STATUSES.has(cacheStatus)) {
416
+ if (hasSetCookie && (!effectivePolicy || !hasFieldQualifiedSetCookie(effectivePolicy.value))) return {
417
+ outcome: "failed",
418
+ error: "response sets a cookie without an observable field-qualified cache policy"
419
+ };
420
+ return { outcome: "warmed" };
421
+ }
422
+ if (cacheStatus && NON_CACHEABLE_CF_CACHE_STATUSES.has(cacheStatus)) return {
423
+ outcome: "skipped",
424
+ reason: nonCacheableHeaders.length > 0 ? `${nonCacheableHeaders.join(", ")} opts out of caching` : hasSetCookie ? "response sets a cookie" : `CF-Cache-Status is ${cacheStatus}`
425
+ };
426
+ if (cacheStatus === "DYNAMIC") {
427
+ if (nonCacheableHeaders.length > 0 || hasSetCookie) return {
428
+ outcome: "skipped",
429
+ reason: nonCacheableHeaders.length > 0 ? `${nonCacheableHeaders.join(", ")} opts out of caching` : "response sets a cookie"
430
+ };
431
+ return {
432
+ outcome: "failed",
433
+ error: "CF-Cache-Status is DYNAMIC"
434
+ };
435
+ }
436
+ if (nonCacheableHeaders.length > 0 || hasSetCookie) return {
437
+ outcome: "failed",
438
+ error: `${nonCacheableHeaders.length > 0 ? `${nonCacheableHeaders.join(", ")} opts out of caching` : "response sets a cookie"}, but CF-Cache-Status is ${cacheStatus ?? "missing"}`
439
+ };
440
+ if (!cacheStatus) return requireCacheStatus ? {
441
+ outcome: "failed",
442
+ error: "response is missing CF-Cache-Status"
443
+ } : { outcome: "warmed" };
444
+ return {
445
+ outcome: "failed",
446
+ error: `CF-Cache-Status is ${cacheStatus}`
447
+ };
448
+ }
449
+ function validateVinextCacheStatus(response, missingStatus = "failed", requireCacheHit = false) {
450
+ const status = response.headers.get(VINEXT_CACHE_HEADER)?.trim().toUpperCase();
451
+ if (requireCacheHit && status !== "HIT") return {
452
+ outcome: "failed",
453
+ error: `${VINEXT_CACHE_HEADER} is ${status ?? "missing"}; the cache fill is not reusable`
454
+ };
455
+ if (status === "MISS" || status === "HIT" || status === "UPDATING") return { outcome: "warmed" };
456
+ if (status === "BYPASS" || status === "DYNAMIC") return {
457
+ outcome: "skipped",
458
+ reason: `${VINEXT_CACHE_HEADER} is ${status}`
459
+ };
460
+ return missingStatus === "skipped" ? {
461
+ outcome: "skipped",
462
+ reason: `response is missing ${VINEXT_CACHE_HEADER}`
463
+ } : {
464
+ outcome: "failed",
465
+ error: `response is missing ${VINEXT_CACHE_HEADER}`
466
+ };
467
+ }
468
+ function validateWarmAdmission(response, statusSource, requireCacheHit) {
469
+ if (statusSource === "vinext") return validateVinextCacheStatus(response, "failed", requireCacheHit);
470
+ if (statusSource === "data-cache") return validateVinextCacheStatus(response, "skipped", requireCacheHit);
471
+ return validateCachePolicy(response, true, requireCacheHit);
472
+ }
473
+ function validateBuildIdentity(response, expectedBuildId) {
474
+ if (expectedBuildId !== void 0 && response.headers.get("X-Vinext-Build-Id") !== expectedBuildId) {
475
+ const actualBuildId = response.headers.get("X-Vinext-Build-Id") ?? "missing";
476
+ return {
477
+ outcome: "failed",
478
+ error: `response ${VINEXT_CDN_BUILD_ID_HEADER} does not match build ${expectedBuildId} (received ${actualBuildId})`
479
+ };
480
+ }
481
+ return null;
482
+ }
483
+ function isExpectedTerminalStatus(status) {
484
+ return status >= 300 && status < 400 || status === 404;
485
+ }
486
+ function validateRscWarmResponse(response, expectedBuildId, expectedRscBuildId, requireCacheHit = false, statusSource = "cloudflare") {
487
+ const buildIdentityValidation = validateBuildIdentity(response, expectedBuildId);
488
+ if (buildIdentityValidation) return buildIdentityValidation;
489
+ if (expectedRscBuildId !== void 0 && response.headers.get(VINEXT_RSC_BUILD_ID_HEADER) !== expectedRscBuildId) return {
490
+ outcome: "failed",
491
+ error: `response ${VINEXT_RSC_BUILD_ID_HEADER} does not match build ${expectedRscBuildId}`
492
+ };
493
+ if (response.redirected) return {
494
+ outcome: "failed",
495
+ error: "redirected response"
496
+ };
497
+ const terminalResponse = response.status < 200 || response.status >= 300;
498
+ if (terminalResponse) {
499
+ if (!isExpectedTerminalStatus(response.status) || expectedBuildId === void 0 && expectedRscBuildId === void 0) return {
500
+ outcome: "failed",
501
+ error: `HTTP ${response.status}`
502
+ };
503
+ } else if (!response.headers.get("Content-Type")?.toLowerCase().startsWith(VINEXT_RSC_CONTENT_TYPE)) return {
504
+ outcome: "failed",
505
+ error: `expected ${VINEXT_RSC_CONTENT_TYPE} response`
506
+ };
507
+ const cachePolicyValidation = validateWarmAdmission(response, statusSource, requireCacheHit);
508
+ if (cachePolicyValidation.outcome !== "warmed") return cachePolicyValidation;
509
+ if (terminalResponse && !response.headers.get("Content-Type")?.toLowerCase().startsWith(VINEXT_RSC_CONTENT_TYPE)) return {
510
+ outcome: "failed",
511
+ error: `expected ${VINEXT_RSC_CONTENT_TYPE} response`
512
+ };
513
+ const vary = new Set((response.headers.get("Vary") ?? "").split(",").map((name) => name.trim().toLowerCase()).filter(Boolean));
514
+ const missingVary = REQUIRED_RSC_VARY_HEADERS.find((name) => !vary.has(name));
515
+ if (missingVary) return {
516
+ outcome: "failed",
517
+ error: `response Vary is missing ${missingVary}`
518
+ };
519
+ return { outcome: "warmed" };
520
+ }
521
+ function validateHtmlWarmResponse(response, expectedBuildId, requireCacheHit = false, statusSource = "cloudflare") {
522
+ const buildIdentityValidation = validateBuildIdentity(response, expectedBuildId);
523
+ if (buildIdentityValidation) return buildIdentityValidation;
524
+ if (response.redirected) return {
525
+ outcome: "failed",
526
+ error: "redirected response"
527
+ };
528
+ if (response.status < 200 || response.status >= 300) {
529
+ if (!isExpectedTerminalStatus(response.status) || expectedBuildId === void 0) return {
530
+ outcome: "failed",
531
+ error: `HTTP ${response.status}`
532
+ };
533
+ }
534
+ const cachePolicyValidation = validateWarmAdmission(response, statusSource, requireCacheHit);
535
+ if (cachePolicyValidation.outcome !== "warmed") return cachePolicyValidation;
536
+ return { outcome: "warmed" };
537
+ }
538
+ function validatePagesDataWarmResponse(response, expectedBuildId, requireCacheHit = false, statusSource = "cloudflare") {
539
+ const validation = validateHtmlWarmResponse(response, expectedBuildId, requireCacheHit, statusSource);
540
+ if (validation.outcome !== "warmed") return validation;
541
+ if (!response.headers.get("Content-Type")?.toLowerCase().startsWith("application/json")) return {
542
+ outcome: "failed",
543
+ error: "expected application/json response"
544
+ };
545
+ return validation;
546
+ }
547
+ function validateReadinessResponse(response, kind, expectedBuildId, expectedRscBuildId) {
548
+ const buildIdentityValidation = validateBuildIdentity(response, expectedBuildId);
549
+ if (buildIdentityValidation?.outcome === "failed") return buildIdentityValidation.error;
550
+ 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}`;
551
+ if (response.redirected) return "redirected response";
552
+ if (response.status >= 500) return `HTTP ${response.status}`;
553
+ if (response.status >= 200 && response.status < 300) {
554
+ const contentType = response.headers.get("Content-Type")?.toLowerCase();
555
+ if (kind === "rsc" && !contentType?.startsWith(VINEXT_RSC_CONTENT_TYPE)) return `expected ${VINEXT_RSC_CONTENT_TYPE} response`;
556
+ if (kind === "pages-data" && !contentType?.startsWith("application/json")) return "expected application/json response";
557
+ }
558
+ return null;
559
+ }
560
+ function validatePrerenderReadinessResponse(response, expectedBuildId) {
561
+ const buildIdentityValidation = validateBuildIdentity(response, expectedBuildId);
562
+ if (buildIdentityValidation?.outcome === "failed") return buildIdentityValidation.error;
563
+ if (response.redirected) return "redirected response";
564
+ if (response.status !== 204) return `expected readiness HTTP 204, received ${response.status}`;
565
+ if (response.headers.get(VINEXT_PRERENDER_READINESS_HEADER) !== "1") return `response is missing ${VINEXT_PRERENDER_READINESS_HEADER}: 1`;
566
+ const cacheControl = response.headers.get("Cache-Control");
567
+ if (!cacheControl || !/(?:^|,)\s*no-store\s*(?:,|$)/i.test(cacheControl)) return "readiness response is missing Cache-Control: no-store";
568
+ return null;
569
+ }
570
+ /**
571
+ * Wait until version-override requests consistently reach the uploaded build
572
+ * before any real cache key is filled. Every probe has a unique query key, so
573
+ * an early response cannot make a later readiness attempt pass from cache.
574
+ */
575
+ async function waitForCdnWarmTargetReadiness(options) {
576
+ const rscPath = options.plan.rscPaths[0] ?? options.plan.loadingShellPaths[0];
577
+ const htmlPath = options.plan.paths[0];
578
+ const pagesDataPath = options.plan.pagesDataPaths[0];
579
+ const routeHandlerPath = options.plan.routeHandlerPaths?.[0];
580
+ const kind = rscPath ? "rsc" : htmlPath ? "html" : pagesDataPath ? "pages-data" : "app-route";
581
+ const pathname = rscPath ?? htmlPath ?? pagesDataPath ?? routeHandlerPath;
582
+ if (!pathname) return { ready: true };
583
+ if (options.expectedBuildId === void 0 && options.expectedRscBuildId === void 0) return {
584
+ error: "no response build identity is available for a staged-version readiness probe",
585
+ ready: false
586
+ };
587
+ const headers = new Headers(options.headers);
588
+ const readinessSecret = options.prerenderSecret;
589
+ const useReadinessEndpoint = Boolean(readinessSecret);
590
+ const probePath = useReadinessEndpoint ? VINEXT_PRERENDER_READINESS_PATH : kind === "rsc" ? createCanonicalRscRequestUrl(pathname) : pathname;
591
+ if (readinessSecret) {
592
+ headers.set("Accept", "text/html");
593
+ headers.set(VINEXT_PRERENDER_SECRET_HEADER, readinessSecret);
594
+ } else if (kind === "rsc") for (const [name, value] of createCanonicalRscRequestHeaders(options.deploymentId)) headers.set(name, value);
595
+ else if (kind === "html") headers.set("Accept", "text/html");
596
+ else if (kind === "pages-data") headers.set("Accept", "application/json");
597
+ else headers.set("Accept", "*/*");
598
+ headers.set("Cache-Control", "no-cache");
599
+ headers.set("Pragma", "no-cache");
600
+ const fetchImpl = options.fetchImpl ?? fetch;
601
+ const timeoutMs = Math.max(1, options.timeoutMs ?? 1e4);
602
+ const probeIntervalMs = Math.max(0, options.probeIntervalMs ?? 1e3);
603
+ const requiredConsecutiveSuccesses = Math.max(1, options.requiredConsecutiveSuccesses ?? DEFAULT_STAGED_READINESS_SUCCESSES);
604
+ const readinessRetries = Math.max(0, options.retries ?? 60);
605
+ const maxAttempts = Math.max(requiredConsecutiveSuccesses, options.maxAttempts ?? requiredConsecutiveSuccesses + readinessRetries);
606
+ const phaseTimeoutMs = Math.max(1, options.phaseTimeoutMs ?? 12e4);
607
+ const deadlineAt = Date.now() + phaseTimeoutMs;
608
+ const probeId = randomUUID();
609
+ let consecutiveSuccesses = 0;
610
+ let lastError = "readiness probe did not run";
611
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
612
+ const remainingMs = deadlineAt - Date.now();
613
+ if (remainingMs <= 0) {
614
+ lastError = `staged readiness exceeded its ${phaseTimeoutMs}ms phase deadline`;
615
+ break;
616
+ }
617
+ const url = buildWarmupUrl(options.targetUrl, probePath);
618
+ url.searchParams.set(STAGED_READINESS_QUERY_PARAM, `${probeId}-${attempt}`);
619
+ let response;
620
+ try {
621
+ response = await fetchHeadersWithTimeout(fetchImpl, url, Math.min(timeoutMs, remainingMs), headers, useReadinessEndpoint ? "POST" : "GET");
622
+ const validationError = useReadinessEndpoint ? validatePrerenderReadinessResponse(response, options.expectedBuildId) : validateReadinessResponse(response, kind, options.expectedBuildId, options.expectedRscBuildId);
623
+ 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"}`);
624
+ if (validationError === null) {
625
+ consecutiveSuccesses++;
626
+ if (consecutiveSuccesses >= requiredConsecutiveSuccesses) return { ready: true };
627
+ } else {
628
+ consecutiveSuccesses = 0;
629
+ lastError = validationError;
630
+ }
631
+ } catch (error) {
632
+ consecutiveSuccesses = 0;
633
+ 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);
634
+ } finally {
635
+ if (response?.body) response.body.cancel().catch(() => {});
636
+ }
637
+ if (attempt + 1 < maxAttempts && probeIntervalMs > 0) {
638
+ const delayMs = Math.min(probeIntervalMs, Math.max(0, deadlineAt - Date.now()));
639
+ if (delayMs <= 0) {
640
+ lastError = `staged readiness exceeded its ${phaseTimeoutMs}ms phase deadline`;
641
+ break;
642
+ }
643
+ await setTimeout$1(delayMs);
644
+ }
112
645
  }
113
646
  return {
114
- path: pathname,
647
+ error: `${lastError}; uploaded build was not stable for ${requiredConsecutiveSuccesses} consecutive probe(s)`,
648
+ ready: false
649
+ };
650
+ }
651
+ function shouldRetryValidationFailure(response, target, options) {
652
+ if (!options.retryPropagationFailures) return isRetryableStatus(response.status, options.retryNotFound);
653
+ 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);
654
+ if (expectedIdentities.some((matches) => matches)) return isRetryableStatus(response.status, false);
655
+ if (expectedIdentities.some((matches) => !matches)) return true;
656
+ return isRetryableStatus(response.status, options.retryNotFound);
657
+ }
658
+ function isRetryableCertificationFailure(response, statusSource, retryPropagationFailures) {
659
+ if (statusSource === "cloudflare") {
660
+ const status = response.headers.get("CF-Cache-Status")?.trim().toUpperCase();
661
+ return !REUSABLE_CF_CACHE_STATUSES.has(status ?? "") && (retryPropagationFailures || ADMITTED_CF_CACHE_STATUSES.has(status ?? ""));
662
+ }
663
+ const status = response.headers.get(VINEXT_CACHE_HEADER)?.trim().toUpperCase();
664
+ return TRANSITIONAL_VINEXT_CACHE_STATUSES.has(status ?? "");
665
+ }
666
+ async function warmOnePath(target, options) {
667
+ const url = buildWarmupUrl(options.targetUrl, target.pathname);
668
+ let lastError = "request failed before the first attempt";
669
+ let lastRetryable = true;
670
+ let lastSkippedReason = null;
671
+ const phaseDeadlineError = () => `CDN warmup exceeded its ${options.phaseTimeoutMs}ms phase deadline`;
672
+ const remainingPhaseMs = () => options.deadlineAt === void 0 ? options.timeoutMs : options.deadlineAt - Date.now();
673
+ const canRetry = (attempt) => attempt < options.retries;
674
+ const waitBeforeRetry = async () => {
675
+ if (options.retryDelayMs <= 0) return remainingPhaseMs() > 0;
676
+ const delayMs = Math.min(options.retryDelayMs, Math.max(0, remainingPhaseMs()));
677
+ if (delayMs <= 0) return false;
678
+ await setTimeout$1(delayMs);
679
+ return remainingPhaseMs() > 0;
680
+ };
681
+ for (let attempt = 0; attempt <= options.retries; attempt++) {
682
+ const remainingMs = remainingPhaseMs();
683
+ if (remainingMs <= 0) return {
684
+ path: target.label,
685
+ ok: false,
686
+ error: phaseDeadlineError(),
687
+ retryable: false
688
+ };
689
+ const attemptTimeoutMs = Math.min(options.timeoutMs, remainingMs);
690
+ const phaseLimitedAttempt = options.deadlineAt !== void 0 && remainingMs <= options.timeoutMs;
691
+ try {
692
+ 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;
693
+ if (options.deadlineAt !== void 0 && Date.now() >= options.deadlineAt) return {
694
+ path: target.label,
695
+ ok: false,
696
+ error: phaseDeadlineError(),
697
+ retryable: false
698
+ };
699
+ if (options.requireCacheHit) response.body?.cancel().catch(() => {});
700
+ 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"}`);
701
+ if (target.kind.startsWith("rsc-")) {
702
+ const validation = validateRscWarmResponse(response, options.expectedBuildId, options.expectedRscBuildId, options.requireCacheHit, options.statusSource);
703
+ if (validation.outcome === "warmed") return {
704
+ path: target.label,
705
+ ok: true,
706
+ skipped: false
707
+ };
708
+ if (validation.outcome === "skipped") {
709
+ if (!options.retrySkipped) return {
710
+ path: target.label,
711
+ ok: true,
712
+ skipped: true,
713
+ reason: validation.reason
714
+ };
715
+ lastSkippedReason = validation.reason;
716
+ lastRetryable = true;
717
+ if (!canRetry(attempt)) break;
718
+ if (!await waitBeforeRetry()) return {
719
+ path: target.label,
720
+ ok: false,
721
+ error: phaseDeadlineError(),
722
+ retryable: false
723
+ };
724
+ continue;
725
+ }
726
+ lastSkippedReason = null;
727
+ lastError = validation.error;
728
+ lastRetryable = options.requireCacheHit && isRetryableCertificationFailure(response, options.statusSource, options.retryPropagationFailures) || shouldRetryValidationFailure(response, target, options);
729
+ if (!lastRetryable) break;
730
+ if (!canRetry(attempt)) break;
731
+ if (!await waitBeforeRetry()) return {
732
+ path: target.label,
733
+ ok: false,
734
+ error: phaseDeadlineError(),
735
+ retryable: false
736
+ };
737
+ continue;
738
+ }
739
+ const validation = target.kind === "pages-data" ? validatePagesDataWarmResponse(response, options.expectedBuildId, options.requireCacheHit, options.statusSource) : validateHtmlWarmResponse(response, options.expectedBuildId, options.requireCacheHit, options.statusSource);
740
+ if (validation.outcome === "warmed") return {
741
+ path: target.label,
742
+ ok: true,
743
+ skipped: false
744
+ };
745
+ if (validation.outcome === "skipped") {
746
+ if (!options.retrySkipped) return {
747
+ path: target.label,
748
+ ok: true,
749
+ skipped: true,
750
+ reason: validation.reason
751
+ };
752
+ lastSkippedReason = validation.reason;
753
+ lastRetryable = true;
754
+ if (!canRetry(attempt)) break;
755
+ if (!await waitBeforeRetry()) return {
756
+ path: target.label,
757
+ ok: false,
758
+ error: phaseDeadlineError(),
759
+ retryable: false
760
+ };
761
+ continue;
762
+ }
763
+ lastSkippedReason = null;
764
+ lastError = validation.error;
765
+ lastRetryable = options.requireCacheHit && isRetryableCertificationFailure(response, options.statusSource, options.retryPropagationFailures) || shouldRetryValidationFailure(response, target, options);
766
+ if (!lastRetryable) break;
767
+ } catch (error) {
768
+ lastSkippedReason = null;
769
+ lastRetryable = true;
770
+ if (error instanceof DOMException && error.name === "AbortError") {
771
+ if (phaseLimitedAttempt) return {
772
+ path: target.label,
773
+ ok: false,
774
+ error: phaseDeadlineError(),
775
+ retryable: false
776
+ };
777
+ lastError = `timed out after ${attemptTimeoutMs}ms`;
778
+ } else lastError = error instanceof Error ? error.message : String(error);
779
+ }
780
+ if (!canRetry(attempt)) break;
781
+ if (!await waitBeforeRetry()) return {
782
+ path: target.label,
783
+ ok: false,
784
+ error: phaseDeadlineError(),
785
+ retryable: false
786
+ };
787
+ }
788
+ return lastSkippedReason === null ? {
789
+ path: target.label,
115
790
  ok: false,
116
- error: lastError
791
+ error: lastError,
792
+ retryable: lastRetryable
793
+ } : {
794
+ path: target.label,
795
+ ok: true,
796
+ skipped: true,
797
+ reason: lastSkippedReason
117
798
  };
118
799
  }
119
800
  async function runWithConcurrency(items, concurrency, fn) {
@@ -131,53 +812,170 @@ async function runWithConcurrency(items, concurrency, fn) {
131
812
  return results;
132
813
  }
133
814
  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);
815
+ const targets = await createCdnWarmTargets(options);
816
+ const requests = targets.filter((target) => target.kind !== "html");
817
+ const htmlRequests = targets.filter((target) => target.kind === "html");
818
+ requests.push(...htmlRequests);
819
+ const timeoutMs = Math.max(1, options.timeoutMs ?? 1e4);
820
+ const hasVersionOverride = new Headers(options.headers).has(WORKER_VERSION_OVERRIDE_HEADER);
821
+ const propagatingTarget = options.propagatingTarget ?? hasVersionOverride;
822
+ const concurrency = Math.max(1, options.concurrency ?? 25);
823
+ const normalRetries = Math.max(0, options.retries ?? 1);
824
+ const propagationRetries = Math.max(0, options.retries ?? 60);
825
+ const normalRetryDelayMs = Math.max(0, options.retryDelayMs ?? 0);
826
+ const propagationRetryDelayMs = Math.max(0, options.retryDelayMs ?? 1e3);
138
827
  const fetchImpl = options.fetchImpl ?? fetch;
139
- if (paths.length === 0) return {
828
+ const phaseTimeoutMs = options.phaseTimeoutMs === void 0 ? void 0 : Math.max(1, options.phaseTimeoutMs);
829
+ const deadlineAt = phaseTimeoutMs === void 0 ? void 0 : Date.now() + phaseTimeoutMs;
830
+ if (requests.length === 0) return {
140
831
  total: 0,
141
832
  warmed: 0,
833
+ skipped: 0,
142
834
  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 }) => ({
835
+ failures: [],
836
+ skippedTargets: [],
837
+ warmedPlan: {
838
+ loadingShellPaths: [],
839
+ pagesDataPaths: [],
840
+ paths: [],
841
+ rscPaths: []
842
+ },
843
+ retryPlan: {
844
+ loadingShellPaths: [],
845
+ pagesDataPaths: [],
846
+ paths: [],
847
+ rscPaths: []
848
+ }
849
+ };
850
+ console.log(`\n Warming ${formatCount(requests.length, "CDN cache entry", "CDN cache entries")}...`);
851
+ printCdnWarmRouteReport(requests);
852
+ const progress = new CdnOperationProgress();
853
+ let completedRequests = 0;
854
+ progress.update(0, requests.length, "starting warmup");
855
+ const warmTarget = (target, retryMode = "normal") => {
856
+ const isPropagationRequest = retryMode !== "normal";
857
+ const retries = retryMode === "propagation-retry" ? Math.max(0, propagationRetries - 1) : retryMode === "propagation-pass" ? 0 : normalRetries;
858
+ return warmOnePath(target, {
859
+ targetUrl: options.targetUrl,
860
+ timeoutMs,
861
+ retries,
862
+ deadlineAt,
863
+ fetchImpl,
864
+ headers: options.headers,
865
+ expectedBuildId: options.expectedBuildId,
866
+ expectedRscBuildId: options.expectedRscBuildId,
867
+ retryPropagationFailures: isPropagationRequest,
868
+ retryDelayMs: isPropagationRequest ? propagationRetryDelayMs : normalRetryDelayMs,
869
+ retryNotFound: isPropagationRequest,
870
+ phaseTimeoutMs,
871
+ requireCacheHit: options.requireCacheHit === true,
872
+ statusSource: options.statusSource ?? "cloudflare",
873
+ retrySkipped: isPropagationRequest && options.retrySkippedTargetKeys?.has(cdnWarmTargetKey(target)) === true
874
+ });
875
+ };
876
+ const warmRequest = async (target, retryMode = "normal") => {
877
+ const result = await warmTarget(target, retryMode);
878
+ completedRequests++;
879
+ progress.update(completedRequests, requests.length, target.label);
880
+ return result;
881
+ };
882
+ const warmPropagatingPass = async (targets) => {
883
+ const results = await runWithConcurrency(targets, concurrency, (target) => warmRequest(target, "propagation-pass"));
884
+ const retryable = targets.map((target, index) => ({
885
+ index,
886
+ result: results[index],
887
+ target
888
+ })).filter(({ result, target }) => result.ok ? result.skipped && options.retrySkippedTargetKeys?.has(cdnWarmTargetKey(target)) === true : result.retryable);
889
+ if (retryable.length === 0 || propagationRetries === 0) return results;
890
+ progress.finish();
891
+ console.log(` CDN warmup: retrying ${retryable.length} unresolved request(s) after completing the initial pass...`);
892
+ let completedRetries = 0;
893
+ progress.update(0, retryable.length, "starting retry pass", "Retrying CDN cache");
894
+ const retried = await runWithConcurrency(retryable.map(({ target }) => target), concurrency, async (target) => {
895
+ const result = await warmTarget(target, "propagation-retry");
896
+ completedRetries++;
897
+ progress.update(completedRetries, retryable.length, target.label, "Retrying CDN cache");
898
+ return result;
899
+ });
900
+ progress.finish();
901
+ for (const [retryIndex, { index }] of retryable.entries()) results[index] = retried[retryIndex];
902
+ return results;
903
+ };
904
+ let results;
905
+ if (propagatingTarget) results = await warmPropagatingPass(requests);
906
+ else results = await runWithConcurrency(requests, concurrency, (target) => warmRequest(target));
907
+ progress.finish();
908
+ const failedRequests = requests.map((target, index) => ({
909
+ result: results[index],
910
+ target
911
+ })).filter((entry) => !entry.result.ok);
912
+ const failures = failedRequests.map(({ result: { path, error } }) => ({
154
913
  path,
155
914
  error
156
915
  }));
157
- const warmed = results.length - failures.length;
158
- console.log(` CDN warmup: ${warmed}/${paths.length} path(s) warmed.`);
916
+ const skippedResults = results.filter((result) => result.ok && result.skipped);
917
+ const skippedTargets = requests.filter((_target, index) => {
918
+ const result = results[index];
919
+ return result.ok && result.skipped;
920
+ });
921
+ const warmedRequests = requests.filter((_target, index) => {
922
+ const result = results[index];
923
+ return result.ok && !result.skipped;
924
+ });
925
+ const warmed = results.length - failures.length - skippedResults.length;
926
+ const warmedRouteHandlerPaths = warmedRequests.filter((target) => target.kind === "app-route").map((target) => target.sourcePathname);
927
+ const failedRouteHandlerPaths = failedRequests.filter(({ target }) => target.kind === "app-route").map(({ target }) => target.sourcePathname);
928
+ printCdnWarmRouteReport(requests, results.map((result) => !result.ok ? "failed" : result.skipped ? "skipped" : "warmed"));
929
+ console.log(` CDN warmup: ${warmed} warmed, ${skippedResults.length} skipped, ${failures.length} failed.`);
930
+ for (const skipped of skippedResults.slice(0, 5)) console.log(` CDN warmup skipped ${skipped.path}: ${skipped.reason}`);
159
931
  if (failures.length > 0) {
160
932
  for (const failure of failures.slice(0, 5)) console.warn(` CDN warmup failed for ${failure.path}: ${failure.error}`);
161
933
  if (failures.length > 5) console.warn(` CDN warmup: ${failures.length - 5} additional failure(s) omitted.`);
162
934
  }
163
935
  const result = {
164
- total: paths.length,
936
+ total: requests.length,
165
937
  warmed,
938
+ skipped: skippedResults.length,
166
939
  failed: failures.length,
167
- failures
940
+ failures,
941
+ skippedTargets,
942
+ warmedPlan: {
943
+ loadingShellPaths: warmedRequests.filter((target) => target.kind === "rsc-loading-shell").map((target) => target.sourcePathname),
944
+ pagesDataPaths: warmedRequests.filter((target) => target.kind === "pages-data").map((target) => target.sourcePathname),
945
+ paths: warmedRequests.filter((target) => target.kind === "html").map((target) => target.sourcePathname),
946
+ rscPaths: warmedRequests.filter((target) => target.kind === "rsc-full").map((target) => target.sourcePathname),
947
+ ...warmedRouteHandlerPaths.length > 0 ? { routeHandlerPaths: warmedRouteHandlerPaths } : {}
948
+ },
949
+ retryPlan: {
950
+ loadingShellPaths: failedRequests.filter(({ target }) => target.kind === "rsc-loading-shell").map(({ target }) => target.sourcePathname),
951
+ pagesDataPaths: failedRequests.filter(({ target }) => target.kind === "pages-data").map(({ target }) => target.sourcePathname),
952
+ paths: failedRequests.filter(({ target }) => target.kind === "html").map(({ target }) => target.sourcePathname),
953
+ rscPaths: failedRequests.filter(({ target }) => target.kind === "rsc-full").map(({ target }) => target.sourcePathname),
954
+ ...failedRouteHandlerPaths.length > 0 ? { routeHandlerPaths: failedRouteHandlerPaths } : {}
955
+ }
168
956
  };
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}`);
957
+ 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
958
  return result;
171
959
  }
172
960
  async function warmCdnCacheFromPrerender(options) {
173
- const paths = readPrerenderWarmPaths(options.root, {
961
+ const plan = readPrerenderWarmPlan(options.root, {
174
962
  includeFallbackShells: options.includeFallbackShells,
175
963
  strict: options.strict
176
964
  });
965
+ const warmPlan = {
966
+ deploymentId: plan.deploymentId,
967
+ loadingShellPaths: plan.loadingShellPaths,
968
+ pagesDataPaths: plan.pagesDataPaths,
969
+ paths: plan.paths,
970
+ routeHandlerPaths: plan.routeHandlerPaths,
971
+ rscPaths: plan.rscPaths
972
+ };
177
973
  return warmCdnCache({
178
974
  ...options,
179
- paths
975
+ ...warmPlan,
976
+ expectedBuildId: options.expectedBuildId ?? (options.validateBuildIdentity === false ? void 0 : plan.buildIdentity),
977
+ expectedRscBuildId: options.expectedRscBuildId ?? plan.rscBuildId
180
978
  });
181
979
  }
182
980
  //#endregion
183
- export { DEFAULT_CDN_WARM_TIMEOUT_MS, buildWarmupUrl, getWarmPathsFromPrerenderManifest, readPrerenderWarmPaths, warmCdnCache, warmCdnCacheFromPrerender };
981
+ 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, createPrerenderWarmPlan, getWarmPathsFromPrerenderManifest, readPrerenderWarmPaths, readPrerenderWarmPlan, waitForCdnWarmTargetReadiness, warmCdnCache, warmCdnCacheFromPrerender };